feat: Add n8n Public API (#3064)

*  Inicial setup

*  Add authentication handler

*  Add GET /users route

*  Improvements

* 👕 Fix linting issues

*  Add GET /users/:identifier endpoint

*  Add POST /users endpoint

*  Add DELETE /users/:identifier endpoint

*  Return error using express native functions

* 👕 Fix linting issue

*  Possibility to add custom middleware

*  Refactor POST /users

*  Refactor DELETE /users

*  Improve cleaning function

*  Refactor GET /users and /users/:identifier

*  Add API spec to route

*  Add raw option to response helper

* 🐛 Fix issue adding custom middleware

*  Enable includeRole parameter in GET /users/:identifier

*  Fix linting issues after merge

*  Add missing config variable

*  General improvements

 asasas

*  Add POST /users tests

* Debug public API tests

* Fix both sets of tests

*  Improvements

*  Load api versions dynamically

*  Add endpoints to UM to create/delete an API Key

*  Add index to apiKey column

* 👕 Fix linting issue

*  Clean open api spec

*  Improvements

*  Skip tests

* 🐛 Fix bug with test

*  Fix issue with the open api spec

*  Fix merge issue

*  Move token enpoints from /users to /me

*  Apply feedback to openapi.yml

*  Improvements to api-key endpoints

* 🐛 Fix test to suport API dynamic loading

*  Expose swagger ui in GET /{version}/docs

*  Allow to disable public api via env variable

*  Change handlers structure

* 🚧 WIP create credential, delete credential complete

* 🐛 fix route for creating api key

*  return api key of authenticated user

*  Expose public api activation to the settings

* ⬆️ Update package-lock.json file

*  Add execution resource

*  Fix linting issues

* 🛠 conditional public api endpoints excluding

* ️ create credential complete

*  Added n8n-card component. Added spacing utility classes.

* ♻️ Made use of n8n-card in existing components.

*  Added api key setup view.

*  Added api keys get/create/delete actions.

*  Added public api permissions handling.

* ♻️ Temporarily disabling card tests.

* ♻️ Changed translations. Storing api key only in component.

*  Added utilities storybook entry

* ♻️ Changed default value for generic copy input.

* 🧹 clean up createCredential

*  Add workflow resource to openapi spec

* 🐛 Fix naming with env variable

*  Allow multifile openapi spec

*  Add POST /workflows/:workflowId/activate

* fix up view, fix issues

* remove delete api key modal

* remove unused prop

* clean up store api

* remove getter

* remove unused dispatch

* fix component size to match

* use existing components

* match figma closely

* fix bug when um is disabled in sidebar

* set copy input color

* remove unused import

*  Remove css path

*  Add POST /workflows/:workflowId/desactivate

*  Add POST /workflows

* Revert " Remove css path"

a3d0a71719

* attempt to fix docker image issue

* revert dockerfile test

* disable public api

* disable api differently

* Revert "disable api differently"

b70e29433e

* Revert "disable public api"

886e5164fb

* remove unused box

*  PUT /workflows/:workflowId

*  Refactor workflow endpoints

*  Refactor executions endpoints

*  Fix typo

*  add credentials tests

*  adjust users tests

* update text

* add try it out link

*  Add delete, getAll and get to the workflow resource

* address spacing comments

* ️ apply correct structure

*  Add missing test to user resource and fix some issues

*  Add workflow tests

*  Add missing workflow tests and fix some issues

*  Executions tests

*  finish execution tests

*  Validate credentials data depending on type

* ️ implement review comments

* 👕 fix lint issues

*  Add apiKey to sanatizeUser

*  Fix issues with spec and tests

*  Add new structure

*  Validate credentials type and properties

*  Make all endpoints except /users independent on UM

*  Add instance base path to swagger UI

*  Remove testing endpoints

*  Fix issue with openapi tags

*  Add endpoint GET /credentialTypes/:id/schema

* 🐛 Fix issue adding json middleware to public api

*  Add API playground path to FE

*  Add telemetry and external hooks

* 🐛 Fix issue with user tests

*  Move /credentialTypes under /credentials

*  Add test to GET /credentials/schema/:id

* 🛠 refactor schema naming

*  Add DB migrations
asas

*  add tests for crd apiKey

*  Added API View telemetry events.

*  Remove rsync from the building process as it is missing on alpine base image

*  add missing BE telemetry events

* 🐛 Fix credential tests

*  address outstanding feedback

* 🔨 Remove move:openapi script

* ⬆️ update dependency

* ⬆️ update package-lock.json

* 👕 Fix linting issue

* 🐛 Fix package.json issue

* 🐛 fix migrations and tests

* 🐛 fix typos + naming

* 🚧 WIP fixing tests

*  Add json schema validation

*  Add missing fields to node schema

*  Add limit max upper limit

*  Rename id paths

* 🐛 Fix tests

* Add package-lock.jsonto custom dockerfile

* ⬆️ Update package-lock.json

* 🐛 Fix issue with build

* ✏️ add beta label to api view

* 🔥 Remove user endpoints

*  Add schema examples to GET /credentials/schema/:id

* 🔥 Remove user endpoints tests

* 🐛 Fix tests

* 🎨 adapt points from design review

* 🔥 remove unnecessary text-align

* ️ update UI

* 🐛 Fix issue with executions filter

*  Add tags filter to GET /workflows

*  Add missing error messages

*  add and update public api tests

*  add tests for owner activiating/deactivating non-owned wfs

* 🧪 add tests for filter for tags

* 🧪 add tests for more filter params

* 🐛 fix inclusion of tags

* 🛠 enhance readability

* ️ small refactorings

* 💄 improving readability/naming

*  Set API latest version dinamically

* Add comments to toJsonSchema function

*  Fix issue

*  Make execution data usable

*  Fix validation issue

*  Rename data field and change parameter and options

* 🐛 Fix issue parameter "detailsFieldFormat" not resolving correctly

* Skip executions tests

* skip workflow failing test

* Rename details property to data

*  Add includeData parameter

* 🐛 Fix issue with openapi spec

* 🐛 Fix linting issue

*  Fix execution schema

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Ben Hesseldieck <b.hesseldieck@gmail.com>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Jan Oberhauser <jan.oberhauser@gmail.com>
This commit is contained in:
Ricardo Espinoza 2022-06-08 14:53:12 -04:00 committed by GitHub
parent 1999f4b066
commit a18081d749
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
112 changed files with 7182 additions and 139 deletions

791
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -582,6 +582,21 @@ export const schema = {
},
},
publicApi: {
disabled: {
format: Boolean,
default: false,
env: 'N8N_PUBLIC_API_DISABLED',
doc: 'Whether to disable the Public API',
},
path: {
format: String,
default: 'api',
env: 'N8N_PUBLIC_API_ENDPOINT',
doc: 'Path for the public api endpoints',
},
},
workflowTagsDisabled: {
format: Boolean,
default: false,

View file

@ -20,7 +20,7 @@
},
"scripts": {
"build": "run-script-os",
"build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email",
"build:default": "tsc && cp -r ./src/UserManagement/email/templates ./dist/src/UserManagement/email && cp ./src/PublicApi/swaggerTheme.css ./dist/src/PublicApi/swaggerTheme.css; find ./src/PublicApi -iname 'openapi.yml' -exec swagger-cli bundle {} --type yaml --outfile \"./dist\"/{} \\;",
"build:windows": "tsc && xcopy /E /I src\\UserManagement\\email\\templates dist\\src\\UserManagement\\email\\templates",
"dev": "concurrently -k -n \"TypeScript,Node\" -c \"yellow.bold,cyan.bold\" \"npm run watch\" \"nodemon\"",
"format": "cd ../.. && node_modules/prettier/bin-prettier.js packages/cli/**/**.ts --write",
@ -37,7 +37,7 @@
"test:postgres:alt-schema": "export DB_POSTGRESDB_SCHEMA=alt_schema; npm run test:postgres",
"test:mysql": "export N8N_LOG_LEVEL=silent; export DB_TYPE=mysqldb; jest",
"watch": "tsc --watch",
"typeorm": "ts-node ../../node_modules/typeorm/cli.js"
"typeorm": "ts-node -T ../../node_modules/typeorm/cli.js"
},
"bin": {
"n8n": "./bin/n8n"
@ -72,8 +72,7 @@
"@types/express": "^4.17.6",
"@types/jest": "^27.4.0",
"@types/localtunnel": "^1.9.0",
"@types/lodash.get": "^4.4.6",
"@types/lodash.merge": "^4.6.6",
"@types/lodash": "^4.14.182",
"@types/node": "14.17.27",
"@types/open": "^6.1.0",
"@types/parseurl": "^1.3.1",
@ -95,11 +94,14 @@
"typescript": "~4.6.0"
},
"dependencies": {
"@apidevtools/swagger-cli": "4.0.0",
"@oclif/command": "^1.5.18",
"@oclif/errors": "^1.2.2",
"@rudderstack/rudder-sdk-node": "1.0.6",
"@types/json-diff": "^0.5.1",
"@types/jsonwebtoken": "^8.5.2",
"@types/swagger-ui-express": "^4.1.3",
"@types/yamljs": "^0.2.31",
"basic-auth": "^2.0.1",
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.3",
@ -117,16 +119,17 @@
"csrf": "^3.1.0",
"dotenv": "^8.0.0",
"express": "^4.16.4",
"express-openapi-validator": "^4.13.6",
"fast-glob": "^3.2.5",
"flatted": "^3.2.4",
"google-timezones-json": "^1.0.2",
"inquirer": "^7.0.1",
"json-diff": "^0.5.4",
"jsonschema": "^1.4.1",
"jsonwebtoken": "^8.5.1",
"jwks-rsa": "~1.12.1",
"localtunnel": "^2.0.0",
"lodash.get": "^4.4.2",
"lodash.merge": "^4.6.2",
"lodash": "^4.17.21",
"mysql2": "~2.3.0",
"n8n-core": "~0.120.0",
"n8n-editor-ui": "~0.146.0",
@ -135,6 +138,7 @@
"nodemailer": "^6.7.1",
"oauth-1.0a": "^2.2.6",
"open": "^7.0.0",
"openapi-types": "^10.0.0",
"p-cancelable": "^2.0.0",
"passport": "^0.5.0",
"passport-cookie": "^1.0.9",
@ -144,10 +148,12 @@
"request-promise-native": "^1.0.7",
"sqlite3": "^5.0.2",
"sse-channel": "^3.1.1",
"swagger-ui-express": "^4.3.0",
"tslib": "1.14.1",
"typeorm": "0.2.30",
"uuid": "^8.3.0",
"validator": "13.7.0",
"winston": "^3.3.3"
"winston": "^3.3.3",
"yamljs": "^0.3.0"
}
}

View file

@ -178,6 +178,8 @@ export interface ICredentialsDecryptedResponse extends ICredentialsDecryptedDb {
export type DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite';
export type SaveExecutionDataType = 'all' | 'none';
export type ExecutionDataFieldFormat = 'empty' | 'flattened' | 'json';
export interface IExecutionBase {
id?: number | string;
mode: WorkflowExecuteMode;
@ -229,6 +231,19 @@ export interface IExecutionFlattedResponse extends IExecutionFlatted {
retryOf?: string;
}
export interface IExecutionResponseApi {
id: number | string;
mode: WorkflowExecuteMode;
startedAt: Date;
stoppedAt?: Date;
workflowId?: string;
finished: boolean;
retryOf?: number | string;
retrySuccessId?: number | string;
data?: string; // Just that we can remove it
waitTill?: Date | null;
workflowData: IWorkflowBase;
}
export interface IExecutionsListResponse {
count: number;
// results: IExecutionShortResponse[];
@ -363,16 +378,20 @@ export interface IInternalHooksClass {
firstWorkflowCreatedAt?: Date,
): Promise<unknown[]>;
onPersonalizationSurveySubmitted(userId: string, answers: Record<string, string>): Promise<void>;
onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise<void>;
onWorkflowDeleted(userId: string, workflowId: string): Promise<void>;
onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise<void>;
onWorkflowCreated(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise<void>;
onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise<void>;
onWorkflowSaved(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise<void>;
onWorkflowPostExecute(
executionId: string,
workflow: IWorkflowBase,
runData?: IRun,
userId?: string,
): Promise<void>;
onUserDeletion(userId: string, userDeletionData: ITelemetryUserDeletionData): Promise<void>;
onUserDeletion(
userId: string,
userDeletionData: ITelemetryUserDeletionData,
publicApi: boolean,
): Promise<void>;
onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise<void>;
onUserReinvite(userReinviteData: { user_id: string; target_user_id: string }): Promise<void>;
onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise<void>;
@ -468,6 +487,7 @@ export interface IN8nUISettings {
personalizationSurveyEnabled: boolean;
defaultLocale: string;
userManagement: IUserManagementSettings;
publicApi: IPublicApiSettings;
workflowTagsDisabled: boolean;
logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
hiringBannerEnabled: boolean;
@ -495,6 +515,11 @@ export interface IUserManagementSettings {
showSetupOnFirstLoad?: boolean;
smtpSetup: boolean;
}
export interface IPublicApiSettings {
enabled: boolean;
latestVersion: number;
path: string;
}
export interface IPackageVersions {
cli: string;

View file

@ -64,24 +64,30 @@ export class InternalHooksClass implements IInternalHooksClass {
);
}
async onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise<void> {
async onWorkflowCreated(
userId: string,
workflow: IWorkflowBase,
publicApi: boolean,
): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
return this.telemetry.track('User created workflow', {
user_id: userId,
workflow_id: workflow.id,
node_graph: nodeGraph,
node_graph_string: JSON.stringify(nodeGraph),
public_api: publicApi,
});
}
async onWorkflowDeleted(userId: string, workflowId: string): Promise<void> {
async onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise<void> {
return this.telemetry.track('User deleted workflow', {
user_id: userId,
workflow_id: workflowId,
public_api: publicApi,
});
}
async onWorkflowSaved(userId: string, workflow: IWorkflowDb): Promise<void> {
async onWorkflowSaved(userId: string, workflow: IWorkflowDb, publicApi: boolean): Promise<void> {
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
const notesCount = Object.keys(nodeGraph.notes).length;
@ -98,6 +104,7 @@ export class InternalHooksClass implements IInternalHooksClass {
notes_count_non_overlapping: notesCount - overlappingCount,
version_cli: this.versionCli,
num_tags: workflow.tags?.length ?? 0,
public_api: publicApi,
});
}
@ -215,21 +222,73 @@ export class InternalHooksClass implements IInternalHooksClass {
async onUserDeletion(
userId: string,
userDeletionData: ITelemetryUserDeletionData,
publicApi: boolean,
): Promise<void> {
return this.telemetry.track('User deleted user', { ...userDeletionData, user_id: userId });
return this.telemetry.track('User deleted user', {
...userDeletionData,
user_id: userId,
public_api: publicApi,
});
}
async onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise<void> {
async onUserInvite(userInviteData: {
user_id: string;
target_user_id: string[];
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User invited new user', userInviteData);
}
async onUserReinvite(userReinviteData: {
user_id: string;
target_user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User resent new user invite email', userReinviteData);
}
async onUserRetrievedUser(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved user', userRetrievedData);
}
async onUserRetrievedAllUsers(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved all users', userRetrievedData);
}
async onUserRetrievedExecution(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved execution', userRetrievedData);
}
async onUserRetrievedAllExecutions(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved all executions', userRetrievedData);
}
async onUserRetrievedWorkflow(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved workflow', userRetrievedData);
}
async onUserRetrievedAllWorkflows(userRetrievedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('User retrieved all workflows', userRetrievedData);
}
async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise<void> {
return this.telemetry.track('User changed personal settings', userUpdateData);
}
@ -248,13 +307,37 @@ export class InternalHooksClass implements IInternalHooksClass {
async onUserTransactionalEmail(userTransactionalEmailData: {
user_id: string;
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
public_api: boolean;
}): Promise<void> {
return this.telemetry.track(
'Instance sent transactional email to user',
'Instance sent transacptional email to user',
userTransactionalEmailData,
);
}
async onUserInvokedApi(userInvokedApiData: {
user_id: string;
path: string;
method: string;
api_version: string;
}): Promise<void> {
return this.telemetry.track('User invoked API', userInvokedApiData);
}
async onApiKeyDeleted(apiKeyDeletedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('API key deleted', apiKeyDeletedData);
}
async onApiKeyCreated(apiKeyCreatedData: {
user_id: string;
public_api: boolean;
}): Promise<void> {
return this.telemetry.track('API key created', apiKeyCreatedData);
}
async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise<void> {
return this.telemetry.track(
'User requested password reset while logged out',
@ -273,6 +356,7 @@ export class InternalHooksClass implements IInternalHooksClass {
async onEmailFailed(failedEmailData: {
user_id: string;
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
public_api: boolean;
}): Promise<void> {
return this.telemetry.track(
'Instance failed to send transactional email to user',

View file

@ -0,0 +1,126 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable no-restricted-syntax */
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable import/no-cycle */
import express, { Router } from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
import { HttpError } from 'express-openapi-validator/dist/framework/types';
import fs from 'fs/promises';
import { OpenAPIV3 } from 'openapi-types';
import path from 'path';
import * as swaggerUi from 'swagger-ui-express';
import validator from 'validator';
import * as YAML from 'yamljs';
import { Db, InternalHooksManager } from '..';
import config from '../../config';
import { getInstanceBaseUrl } from '../UserManagement/UserManagementHelper';
function createApiRouter(
version: string,
openApiSpecPath: string,
hanldersDirectory: string,
swaggerThemeCss: string,
publicApiEndpoint: string,
): Router {
const n8nPath = config.getEnv('path');
const swaggerDocument = YAML.load(openApiSpecPath) as swaggerUi.JsonObject;
// add the server depeding on the config so the user can interact with the API
// from the swagger UI
swaggerDocument.server = [
{
url: `${getInstanceBaseUrl()}/${publicApiEndpoint}/${version}}`,
},
];
const apiController = express.Router();
apiController.use(
`/${publicApiEndpoint}/${version}/docs`,
swaggerUi.serveFiles(swaggerDocument),
swaggerUi.setup(swaggerDocument, {
customCss: swaggerThemeCss,
customSiteTitle: 'n8n Public API UI',
customfavIcon: `${n8nPath}favicon.ico`,
}),
);
apiController.use(`/${publicApiEndpoint}/${version}`, express.json());
apiController.use(
`/${publicApiEndpoint}/${version}`,
OpenApiValidator.middleware({
apiSpec: openApiSpecPath,
operationHandlers: hanldersDirectory,
validateRequests: true,
validateApiSpec: true,
formats: [
{
name: 'email',
type: 'string',
validate: (email: string) => validator.isEmail(email),
},
{
name: 'identifier',
type: 'string',
validate: (identifier: string) =>
validator.isUUID(identifier) || validator.isEmail(identifier),
},
],
validateSecurity: {
handlers: {
ApiKeyAuth: async (
req: express.Request,
_scopes: unknown,
schema: OpenAPIV3.ApiKeySecurityScheme,
): Promise<boolean> => {
const apiKey = req.headers[schema.name.toLowerCase()];
const user = await Db.collections.User?.findOne({
where: {
apiKey,
},
relations: ['globalRole'],
});
if (!user) {
return false;
}
void InternalHooksManager.getInstance().onUserInvokedApi({
user_id: user.id,
path: req.path,
method: req.method,
api_version: version,
});
req.user = user;
return true;
},
},
},
}),
);
apiController.use(
(error: HttpError, req: express.Request, res: express.Response, next: express.NextFunction) => {
return res.status(error.status || 400).json({
message: error.message,
});
},
);
return apiController;
}
export const loadPublicApiVersions = async (
publicApiEndpoint: string,
): Promise<{ apiRouters: express.Router[]; apiLatestVersion: number }> => {
const swaggerThemePath = path.join(__dirname, 'swaggerTheme.css');
const folders = await fs.readdir(__dirname);
const css = (await fs.readFile(swaggerThemePath)).toString();
const versions = folders.filter((folderName) => folderName.startsWith('v'));
const apiRouters: express.Router[] = [];
for (const version of versions) {
const openApiPath = path.join(__dirname, version, 'openapi.yml');
apiRouters.push(createApiRouter(version, openApiPath, __dirname, css, publicApiEndpoint));
}
return {
apiRouters,
apiLatestVersion: Number(versions.pop()?.charAt(1)) ?? 1,
};
};

File diff suppressed because one or more lines are too long

165
packages/cli/src/PublicApi/types.d.ts vendored Normal file
View file

@ -0,0 +1,165 @@
/* eslint-disable import/no-cycle */
import express from 'express';
import { IDataObject } from 'n8n-workflow';
import type { User } from '../databases/entities/User';
import type { Role } from '../databases/entities/Role';
import type { WorkflowEntity } from '../databases/entities/WorkflowEntity';
import * as UserManagementMailer from '../UserManagement/email/UserManagementMailer';
export type ExecutionStatus = 'error' | 'running' | 'success' | 'waiting' | null;
export type AuthlessRequest<
RouteParams = {},
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery>;
export type AuthenticatedRequest<
RouteParams = {},
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
user: User;
globalMemberRole?: Role;
mailer?: UserManagementMailer.UserManagementMailer;
};
export type PaginatatedRequest = AuthenticatedRequest<
{},
{},
{},
{
limit?: number;
cursor?: string;
offset?: number;
lastId?: number;
}
>;
export declare namespace ExecutionRequest {
type GetAll = AuthenticatedRequest<
{},
{},
{},
{
status?: ExecutionStatus;
limit?: number;
cursor?: string;
offset?: number;
includeData?: boolean;
workflowId?: number;
lastId?: number;
}
>;
type Get = AuthenticatedRequest<{ id: number }, {}, {}, { includeData?: boolean }>;
type Delete = Get;
}
export declare namespace CredentialTypeRequest {
type Get = AuthenticatedRequest<{ credentialTypeName: string }, {}, {}, {}>;
}
export declare namespace WorkflowRequest {
type GetAll = AuthenticatedRequest<
{},
{},
{},
{
tags?: string;
status?: ExecutionStatus;
limit?: number;
cursor?: string;
offset?: number;
workflowId?: number;
active: boolean;
}
>;
type Create = AuthenticatedRequest<{}, {}, WorkflowEntity, {}>;
type Get = AuthenticatedRequest<{ id: number }, {}, {}, {}>;
type Delete = Get;
type Update = AuthenticatedRequest<{ id: number }, {}, WorkflowEntity, {}>;
type Activate = Get;
}
export declare namespace UserRequest {
export type Invite = AuthenticatedRequest<{}, {}, Array<{ email: string }>>;
export type ResolveSignUp = AuthlessRequest<
{},
{},
{},
{ inviterId?: string; inviteeId?: string }
>;
export type SignUp = AuthenticatedRequest<
{ id: string },
{ inviterId?: string; inviteeId?: string }
>;
export type Delete = AuthenticatedRequest<
{ id: string; email: string },
{},
{},
{ transferId?: string; includeRole: boolean }
>;
export type Get = AuthenticatedRequest<
{ id: string; email: string },
{},
{},
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
>;
export type Reinvite = AuthenticatedRequest<{ id: string }>;
export type Update = AuthlessRequest<
{ id: string },
{},
{
inviterId: string;
firstName: string;
lastName: string;
password: string;
}
>;
}
export declare namespace CredentialRequest {
type Create = AuthenticatedRequest<{}, {}, { type: string; name: string; data: IDataObject }, {}>;
}
export type OperationID = 'getUsers' | 'getUser';
type PaginationBase = { limit: number };
type PaginationOffsetDecoded = PaginationBase & { offset: number };
type PaginationCursorDecoded = PaginationBase & { lastId: number };
type OffsetPagination = PaginationBase & { offset: number; numberOfTotalRecords: number };
type CursorPagination = PaginationBase & { lastId: number; numberOfNextRecords: number };
export interface IRequired {
required?: string[];
not?: { required?: string[] };
}
export interface IDependency {
if?: { properties: {} };
then?: { oneOf: IRequired[] };
else?: { allOf: IRequired[] };
}
export interface IJsonSchema {
additionalProperties: boolean;
type: 'object';
properties: { [key: string]: { type: string } };
allOf?: IDependency[];
required: string[];
}

View file

@ -0,0 +1,112 @@
import express = require('express');
import { CredentialsHelper } from '../../../../CredentialsHelper';
import { CredentialTypes } from '../../../../CredentialTypes';
import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity';
import { CredentialRequest } from '../../../../requests';
import { CredentialTypeRequest } from '../../../types';
import { authorize } from '../../shared/middlewares/global.middleware';
import { validCredentialsProperties, validCredentialType } from './credentials.middleware';
import {
createCredential,
encryptCredential,
getCredentials,
getSharedCredentials,
removeCredential,
sanitizeCredentials,
saveCredential,
toJsonSchema,
} from './credentials.service';
export = {
createCredential: [
authorize(['owner', 'member']),
validCredentialType,
validCredentialsProperties,
async (
req: CredentialRequest.Create,
res: express.Response,
): Promise<express.Response<Partial<CredentialsEntity>>> => {
try {
const newCredential = await createCredential(req.body as Partial<CredentialsEntity>);
const encryptedData = await encryptCredential(newCredential);
Object.assign(newCredential, encryptedData);
const savedCredential = await saveCredential(newCredential, req.user, encryptedData);
// LoggerProxy.verbose('New credential created', {
// credentialId: newCredential.id,
// ownerId: req.user.id,
// });
return res.json(sanitizeCredentials(savedCredential));
} catch ({ message, httpStatusCode }) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
return res.status(httpStatusCode ?? 500).json({ message });
}
},
],
deleteCredential: [
authorize(['owner', 'member']),
async (
req: CredentialRequest.Delete,
res: express.Response,
): Promise<express.Response<Partial<CredentialsEntity>>> => {
const { id: credentialId } = req.params;
let credentials: CredentialsEntity | undefined;
if (req.user.globalRole.name !== 'owner') {
const shared = await getSharedCredentials(req.user.id, credentialId, [
'credentials',
'role',
]);
if (shared?.role.name === 'owner') {
credentials = shared.credentials;
} else {
// LoggerProxy.info('Attempt to delete credential blocked due to lack of permissions', {
// credentialId,
// userId: req.user.id,
// });
}
} else {
credentials = (await getCredentials(credentialId)) as CredentialsEntity;
}
if (!credentials) {
return res.status(404).json({
message: 'Not Found',
});
}
await removeCredential(credentials);
credentials.id = Number(credentialId);
return res.json(sanitizeCredentials(credentials));
},
],
getCredentialType: [
authorize(['owner', 'member']),
async (req: CredentialTypeRequest.Get, res: express.Response): Promise<express.Response> => {
const { credentialTypeName } = req.params;
try {
CredentialTypes().getByName(credentialTypeName);
} catch (error) {
return res.status(404).json({
message: 'Not Found',
});
}
let schema = new CredentialsHelper('').getCredentialsProperties(credentialTypeName);
schema = schema.filter((nodeProperty) => nodeProperty.type !== 'hidden');
return res.json(toJsonSchema(schema));
},
],
};

View file

@ -0,0 +1,47 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable consistent-return */
import { RequestHandler } from 'express';
import { validate } from 'jsonschema';
import { CredentialsHelper, CredentialTypes } from '../../../..';
import { CredentialRequest } from '../../../types';
import { toJsonSchema } from './credentials.service';
export const validCredentialType: RequestHandler = async (
req: CredentialRequest.Create,
res,
next,
): Promise<any> => {
const { type } = req.body;
try {
CredentialTypes().getByName(type);
} catch (error) {
return res.status(400).json({
message: 'req.body.type is not a known type',
});
}
next();
};
export const validCredentialsProperties: RequestHandler = async (
req: CredentialRequest.Create,
res,
next,
): Promise<any> => {
const { type, data } = req.body;
let properties = new CredentialsHelper('').getCredentialsProperties(type);
properties = properties.filter((nodeProperty) => nodeProperty.type !== 'hidden');
const schema = toJsonSchema(properties);
const { valid, errors } = validate(data, schema, { nestedErrors: true });
if (!valid) {
return res.status(400).json({
message: errors.map((error) => `request.body.data ${error.message}`).join(','),
});
}
next();
};

View file

@ -0,0 +1,249 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { FindOneOptions } from 'typeorm';
import { UserSettings, Credentials } from 'n8n-core';
import { IDataObject, INodeProperties, INodePropertyOptions } from 'n8n-workflow';
import { Db, ICredentialsDb } from '../../../..';
import { CredentialsEntity } from '../../../../databases/entities/CredentialsEntity';
import { SharedCredentials } from '../../../../databases/entities/SharedCredentials';
import { User } from '../../../../databases/entities/User';
import { externalHooks } from '../../../../Server';
import { IDependency, IJsonSchema } from '../../../types';
export async function getCredentials(
credentialId: number | string,
): Promise<ICredentialsDb | undefined> {
return Db.collections.Credentials.findOne(credentialId);
}
export async function getSharedCredentials(
userId: string,
credentialId: number | string,
relations?: string[],
): Promise<SharedCredentials | undefined> {
const options: FindOneOptions = {
where: {
user: { id: userId },
credentials: { id: credentialId },
},
};
if (relations) {
options.relations = relations;
}
return Db.collections.SharedCredentials.findOne(options);
}
export async function createCredential(
properties: Partial<CredentialsEntity>,
): Promise<CredentialsEntity> {
const newCredential = new CredentialsEntity();
Object.assign(newCredential, properties);
if (!newCredential.nodesAccess || newCredential.nodesAccess.length === 0) {
newCredential.nodesAccess = [
{
nodeType: `n8n-nodes-base.${properties.type?.toLowerCase() ?? 'unknown'}`,
date: new Date(),
},
];
} else {
// Add the added date for node access permissions
newCredential.nodesAccess.forEach((nodeAccess) => {
// eslint-disable-next-line no-param-reassign
nodeAccess.date = new Date();
});
}
return newCredential;
}
export async function saveCredential(
credential: CredentialsEntity,
user: User,
encryptedData: ICredentialsDb,
): Promise<CredentialsEntity> {
const role = await Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'credential',
});
await externalHooks.run('credentials.create', [encryptedData]);
return Db.transaction(async (transactionManager) => {
const savedCredential = await transactionManager.save<CredentialsEntity>(credential);
savedCredential.data = credential.data;
const newSharedCredential = new SharedCredentials();
Object.assign(newSharedCredential, {
role,
user,
credentials: savedCredential,
});
await transactionManager.save<SharedCredentials>(newSharedCredential);
return savedCredential;
});
}
export async function removeCredential(credentials: CredentialsEntity): Promise<ICredentialsDb> {
await externalHooks.run('credentials.delete', [credentials.id]);
return Db.collections.Credentials.remove(credentials);
}
export async function encryptCredential(credential: CredentialsEntity): Promise<ICredentialsDb> {
const encryptionKey = await UserSettings.getEncryptionKey();
// Encrypt the data
const coreCredential = new Credentials(
{ id: null, name: credential.name },
credential.type,
credential.nodesAccess,
);
// @ts-ignore
coreCredential.setData(credential.data, encryptionKey);
return coreCredential.getDataToSave() as ICredentialsDb;
}
export function sanitizeCredentials(credentials: CredentialsEntity): Partial<CredentialsEntity>;
export function sanitizeCredentials(
credentials: CredentialsEntity[],
): Array<Partial<CredentialsEntity>>;
export function sanitizeCredentials(
credentials: CredentialsEntity | CredentialsEntity[],
): Partial<CredentialsEntity> | Array<Partial<CredentialsEntity>> {
const argIsArray = Array.isArray(credentials);
const credentialsList = argIsArray ? credentials : [credentials];
const sanitizedCredentials = credentialsList.map((credential) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { data, nodesAccess, shared, ...rest } = credential;
return rest;
});
return argIsArray ? sanitizedCredentials : sanitizedCredentials[0];
}
/**
* toJsonSchema
* Take an array of crendentials parameter and map it
* to a JSON Schema (see https://json-schema.org/). With
* the JSON Schema defintion we can validate the credential's shape
* @param properties - Credentials properties
* @returns The credentials schema definition.
*/
export function toJsonSchema(properties: INodeProperties[]): IDataObject {
const jsonSchema: IJsonSchema = {
additionalProperties: false,
type: 'object',
properties: {},
allOf: [],
required: [],
};
const optionsValues: { [key: string]: string[] } = {};
const resolveProperties: string[] = [];
// get all posible values of properties type "options"
// so we can later resolve the displayOptions dependencies
properties
.filter((property) => property.type === 'options')
.forEach((property) => {
Object.assign(optionsValues, {
[property.name]: property.options?.map((option: INodePropertyOptions) => option.value),
});
});
let requiredFields: string[] = [];
const propertyRequiredDependencies: { [key: string]: IDependency } = {};
// add all credential's properties to the properties
// object in the JSON Schema definition. This allows us
// to later validate that only this properties are set in
// the credentials sent in the API call.
properties.forEach((property) => {
requiredFields.push(property.name);
if (property.type === 'options') {
// if the property is type options,
// include all possible values in the anum property.
Object.assign(jsonSchema.properties, {
[property.name]: {
type: 'string',
enum: property.options?.map((data: INodePropertyOptions) => data.value),
},
});
} else {
Object.assign(jsonSchema.properties, {
[property.name]: {
type: property.type,
},
});
}
// if the credential property has a dependency
// then add a JSON Schema condition that satisfy each property value
// e.x: If A has value X then required B, else required C
// see https://json-schema.org/understanding-json-schema/reference/conditionals.html#if-then-else
if (property.displayOptions?.show) {
const dependantName = Object.keys(property.displayOptions?.show)[0] || '';
const displayOptionsValues = property.displayOptions.show[dependantName];
let dependantValue: string | number | boolean = '';
if (displayOptionsValues && Array.isArray(displayOptionsValues) && displayOptionsValues[0]) {
// eslint-disable-next-line prefer-destructuring
dependantValue = displayOptionsValues[0];
}
if (propertyRequiredDependencies[dependantName] === undefined) {
propertyRequiredDependencies[dependantName] = {};
}
if (!resolveProperties.includes(dependantName)) {
propertyRequiredDependencies[dependantName] = {
if: {
properties: {
[dependantName]: {
enum: [dependantValue],
},
},
},
then: {
oneOf: [],
},
else: {
allOf: [],
},
};
}
propertyRequiredDependencies[dependantName].then?.oneOf.push({ required: [property.name] });
propertyRequiredDependencies[dependantName].else?.allOf.push({
not: { required: [property.name] },
});
resolveProperties.push(dependantName);
// remove global required
requiredFields = requiredFields.filter((field) => field !== property.name);
}
});
Object.assign(jsonSchema, { required: requiredFields });
jsonSchema.allOf = Object.values(propertyRequiredDependencies);
if (!jsonSchema.allOf.length) {
delete jsonSchema.allOf;
}
return jsonSchema as unknown as IDataObject;
}

View file

@ -0,0 +1,26 @@
delete:
x-eov-operation-id: deleteCredential
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Delete credential by ID
description: Deletes a credential from your instance. You must be the owner of the credentials
operationId: deleteCredential
parameters:
- name: id
in: path
description: The credential ID that needs to be deleted
required: true
schema:
type: number
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/credential.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,37 @@
get:
x-eov-operation-id: getCredentialType
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Show credential data schema
parameters:
- name: credentialTypeName
in: path
description: The credential type name that you want to get the schema for
required: true
schema:
type: string
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
type: object
examples:
freshdeskApi:
value:
additionalProperties: false
type: 'object'
properties: { apiKey: { type: 'string' }, domain: { type: 'string' } }
required: ['apiKey', 'domain']
slackOAuth2Api:
value:
additionalProperties: false
type: 'object'
properties: { clientId: { type: 'string' }, clientSecret: { type: 'string' } }
required: ['clientId', 'clientSecret']
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,25 @@
post:
x-eov-operation-id: createCredential
x-eov-operation-handler: v1/handlers/credentials/credentials.handler
tags:
- Credential
summary: Create a credential
description: Creates a credential that can be used by nodes of the specified type.
requestBody:
description: Credential to be created.
required: true
content:
application/json:
schema:
$ref: '../schemas/credential.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/credential.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'415':
description: Unsupported media type.

View file

@ -0,0 +1,30 @@
required:
- name
- type
- data
type: object
properties:
id:
type: number
readOnly: true
example: 42
name:
type: string
example: Joe's Github Credentials
type:
type: string
example: github
data:
type: object
writeOnly: true
example: { token: 'ada612vad6fa5df4adf5a5dsf4389adsf76da7s' }
createdAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'
updatedAt:
type: string
format: date-time
readOnly: true
example: '2022-04-29T11:02:29.842Z'

View file

@ -0,0 +1,18 @@
type: object
properties:
displayName:
type: string
readOnly: true
example: Email
name:
type: string
readOnly: true
example: email
type:
type: string
readOnly: true
example: string
default:
type: string
readOnly: true
example: string

View file

@ -0,0 +1,154 @@
import express = require('express');
import { BinaryDataManager } from 'n8n-core';
import {
getExecutions,
getExecutionInWorkflows,
deleteExecution,
getExecutionsCount,
} from './executions.service';
import { ActiveExecutions } from '../../../..';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { ExecutionRequest } from '../../../types';
import { getSharedWorkflowIds } from '../workflows/workflows.service';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { InternalHooksManager } from '../../../../InternalHooksManager';
export = {
deleteExecution: [
authorize(['owner', 'member']),
async (req: ExecutionRequest.Delete, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
// user does not have workflows hence no executions
// or the execution he is trying to access belongs to a workflow he does not own
if (!sharedWorkflowsIds.length) {
return res.status(404).json({
message: 'Not Found',
});
}
// look for the execution on the workflow the user owns
const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, false);
// execution was not found
if (!execution) {
return res.status(404).json({
message: 'Not Found',
});
}
const binaryDataManager = BinaryDataManager.getInstance();
await binaryDataManager.deleteBinaryDataByExecutionId(execution.id.toString());
await deleteExecution(execution);
execution.id = id;
return res.json(execution);
},
],
getExecution: [
authorize(['owner', 'member']),
async (req: ExecutionRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const { includeData = false } = req.query;
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
// user does not have workflows hence no executions
// or the execution he is trying to access belongs to a workflow he does not own
if (!sharedWorkflowsIds.length) {
return res.status(404).json({
message: 'Not Found',
});
}
// look for the execution on the workflow the user owns
const execution = await getExecutionInWorkflows(id, sharedWorkflowsIds, includeData);
// execution was not found
if (!execution) {
return res.status(404).json({
message: 'Not Found',
});
}
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void InternalHooksManager.getInstance().onUserRetrievedExecution(telemetryData);
return res.json(execution);
},
],
getExecutions: [
authorize(['owner', 'member']),
validCursor,
async (req: ExecutionRequest.GetAll, res: express.Response): Promise<express.Response> => {
const {
lastId = undefined,
limit = 100,
status = undefined,
includeData = false,
workflowId = undefined,
} = req.query;
const sharedWorkflowsIds = await getSharedWorkflowIds(req.user);
// user does not have workflows hence no executions
// or the execution he is trying to access belongs to a workflow he does not own
if (!sharedWorkflowsIds.length) {
return res.status(200).json({
data: [],
nextCursor: null,
});
}
// get running workflows so we exclude them from the result
const runningExecutionsIds = ActiveExecutions.getInstance()
.getActiveExecutions()
.map(({ id }) => Number(id));
const filters = {
status,
limit,
lastId,
includeData,
...(workflowId && { workflowIds: [workflowId] }),
excludedExecutionsIds: runningExecutionsIds,
};
const executions = await getExecutions(filters);
const newLastId = !executions.length ? 0 : (executions.slice(-1)[0].id as number);
filters.lastId = newLastId;
const count = await getExecutionsCount(filters);
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void InternalHooksManager.getInstance().onUserRetrievedAllExecutions(telemetryData);
return res.json({
data: executions,
nextCursor: encodeNextCursor({
lastId: newLastId,
limit,
numberOfNextRecords: count,
}),
});
},
],
};

View file

@ -0,0 +1,115 @@
import { parse } from 'flatted';
import { In, Not, ObjectLiteral, LessThan, IsNull } from 'typeorm';
import { Db, IExecutionFlattedDb, IExecutionResponseApi } from '../../../..';
import { ExecutionStatus } from '../../../types';
function prepareExecutionData(
execution: IExecutionFlattedDb | undefined,
): IExecutionResponseApi | undefined {
if (execution === undefined) {
return undefined;
}
if (!execution.data) {
return execution;
}
return {
...execution,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
data: parse(execution.data),
};
}
function getStatusCondition(status: ExecutionStatus): ObjectLiteral {
const condition: ObjectLiteral = {};
if (status === 'success') {
condition.finished = true;
} else if (status === 'waiting') {
condition.waitTill = Not(IsNull());
} else if (status === 'error') {
condition.stoppedAt = Not(IsNull());
condition.finished = false;
}
return condition;
}
function getExecutionSelectableProperties(includeData?: boolean): Array<keyof IExecutionFlattedDb> {
const returnData: Array<keyof IExecutionFlattedDb> = [
'id',
'mode',
'retryOf',
'retrySuccessId',
'startedAt',
'stoppedAt',
'workflowId',
'waitTill',
'finished',
];
if (includeData) {
returnData.push('data');
}
return returnData;
}
export async function getExecutions(data: {
limit: number;
includeData?: boolean;
lastId?: number;
workflowIds?: number[];
status?: ExecutionStatus;
excludedExecutionsIds?: number[];
}): Promise<IExecutionResponseApi[]> {
const executions = await Db.collections.Execution.find({
select: getExecutionSelectableProperties(data.includeData),
where: {
...(data.lastId && { id: LessThan(data.lastId) }),
...(data.status && { ...getStatusCondition(data.status) }),
...(data.workflowIds && { workflowId: In(data.workflowIds.map(String)) }),
...(data.excludedExecutionsIds && { id: Not(In(data.excludedExecutionsIds)) }),
},
order: { id: 'DESC' },
take: data.limit,
});
return executions.map((execution) => prepareExecutionData(execution)) as IExecutionResponseApi[];
}
export async function getExecutionsCount(data: {
limit: number;
lastId?: number;
workflowIds?: number[];
status?: ExecutionStatus;
excludedWorkflowIds?: number[];
}): Promise<number> {
const executions = await Db.collections.Execution.count({
where: {
...(data.lastId && { id: LessThan(data.lastId) }),
...(data.status && { ...getStatusCondition(data.status) }),
...(data.workflowIds && { workflowId: In(data.workflowIds) }),
...(data.excludedWorkflowIds && { workflowId: Not(In(data.excludedWorkflowIds)) }),
},
take: data.limit,
});
return executions;
}
export async function getExecutionInWorkflows(
id: number,
workflows: number[],
includeData?: boolean,
): Promise<IExecutionResponseApi | undefined> {
const execution = await Db.collections.Execution.findOne({
select: getExecutionSelectableProperties(includeData),
where: {
id,
workflowId: In(workflows),
},
});
return prepareExecutionData(execution);
}
export async function deleteExecution(execution: IExecutionResponseApi | undefined): Promise<void> {
await Db.collections.Execution.remove(execution as IExecutionFlattedDb);
}

View file

@ -0,0 +1,41 @@
get:
x-eov-operation-id: getExecution
x-eov-operation-handler: v1/handlers/executions/executions.handler
tags:
- Execution
summary: Retrieve an execution
description: Retrieve an execution from you instance.
parameters:
- $ref: '../schemas/parameters/executionId.yml'
- $ref: '../schemas/parameters/includeData.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/execution.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
delete:
x-eov-operation-id: deleteExecution
x-eov-operation-handler: v1/handlers/executions/executions.handler
tags:
- Execution
summary: Delete an execution
description: Deletes an execution from your instance.
parameters:
- $ref: '../schemas/parameters/executionId.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/execution.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,36 @@
get:
x-eov-operation-id: getExecutions
x-eov-operation-handler: v1/handlers/executions/executions.handler
tags:
- Execution
summary: Retrieve all executions
description: Retrieve all executions from your instance.
parameters:
- $ref: '../schemas/parameters/includeData.yml'
- name: status
in: query
description: Status to filter the executions by.
required: false
schema:
type: string
enum: ['error', 'success', 'waiting']
- name: workflowId
in: query
description: Workflow to filter the executions by.
required: false
schema:
type: number
example: 1000
- $ref: '../../../../shared/spec/parameters/limit.yml'
- $ref: '../../../../shared/spec/parameters/cursor.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/executionList.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,33 @@
type: object
properties:
id:
type: number
example: 1000
data:
type: object
finished:
type: boolean
example: true
mode:
type: string
enum: ['cli', 'error', 'integrated', 'internal', 'manual', 'retry', 'trigger', 'webhook']
retryOf:
type: string
nullable: true
retrySuccessId:
type: string
nullable: true
example: 2
startedAt:
type: string
format: date-time
stoppedAt:
type: string
format: date-time
workflowId:
type: string
example: 1000
waitTill:
type: string
nullable: true
format: date-time

View file

@ -0,0 +1,11 @@
type: object
properties:
data:
type: array
items:
$ref: './execution.yml'
nextCursor:
type: string
description: Paginate through executions by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View file

@ -0,0 +1,6 @@
name: id
in: path
description: The ID of the execution.
required: true
schema:
type: number

View file

@ -0,0 +1,6 @@
name: includeData
in: query
description: Whether or not to include the execution's detailed data.
required: false
schema:
type: boolean

View file

@ -0,0 +1,14 @@
import { Db } from '../../../..';
import { Role } from '../../../../databases/entities/Role';
import { User } from '../../../../databases/entities/User';
export function isInstanceOwner(user: User): boolean {
return user.globalRole.name === 'owner';
}
export async function getWorkflowOwnerRole(): Promise<Role> {
return Db.collections.Role.findOneOrFail({
name: 'owner',
scope: 'workflow',
});
}

View file

@ -0,0 +1,20 @@
post:
x-eov-operation-id: activateWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Activate a workflow
description: Active a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
responses:
'200':
description: Workflow object
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,20 @@
post:
x-eov-operation-id: deactivateWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Deactivate a workflow
description: Deactivate a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
responses:
'200':
description: Workflow object
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,69 @@
get:
x-eov-operation-id: getWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Retrieves a workflow
description: Retrieves a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
delete:
x-eov-operation-id: deleteWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Delete a workflow
description: Deletes a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'
put:
x-eov-operation-id: updateWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Update a workflow
description: Update a workflow.
parameters:
- $ref: '../schemas/parameters/workflowId.yml'
requestBody:
description: Updated workflow object.
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
required: true
responses:
'200':
description: Workflow object
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
'404':
$ref: '../../../../shared/spec/responses/notFound.yml'

View file

@ -0,0 +1,57 @@
post:
x-eov-operation-id: createWorkflow
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Create a workflow
description: Create a workflow in your instance.
requestBody:
description: Created workflow object.
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
required: true
responses:
'200':
description: A workflow object
content:
application/json:
schema:
$ref: '../schemas/workflow.yml'
'400':
$ref: '../../../../shared/spec/responses/badRequest.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'
get:
x-eov-operation-id: getWorkflows
x-eov-operation-handler: v1/handlers/workflows/workflows.handler
tags:
- Workflow
summary: Retrieve all workflows
description: Retrieve all workflows from your instance.
parameters:
- name: active
in: query
schema:
type: boolean
example: true
- name: tags
in: query
required: false
explode: false
allowReserved: true
schema:
type: string
example: test,production
- $ref: '../../../../shared/spec/parameters/limit.yml'
- $ref: '../../../../shared/spec/parameters/cursor.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/workflowList.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'

View file

@ -0,0 +1,39 @@
type: object
additionalProperties: false
properties:
name:
type: string
example: Jira
webhookId:
type: string
disabled:
type: boolean
notesInFlow:
type: boolean
notes:
type: string
type:
type: string
example: n8n-nodes-base.Jira
typeVersion:
type: number
example: 1
position:
type: array
items:
type: number
example: [-100, 80]
parameters:
type: object
example: { additionalProperties: {} }
credentials:
type: object
example: { jiraSoftwareCloudApi: { id: "35", name: "jiraApi"} }
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true

View file

@ -0,0 +1,6 @@
name: id
in: path
description: The ID of the workflow.
required: true
schema:
type: number

View file

@ -0,0 +1,16 @@
type: object
properties:
id:
type: string
example: 12
name:
type: string
example: Production
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true

View file

@ -0,0 +1,43 @@
type: object
required:
- name
- nodes
- connections
- settings
properties:
id:
type: number
readOnly: true
example: 1
name:
type: string
example: Workflow 1
active:
type: boolean
readOnly: true
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
nodes:
type: array
items:
$ref: './node.yml'
connections:
type: object
example: { main: [{ node: 'Jira', type: 'main', index: 0 }] }
settings:
$ref: './workflowSettings.yml'
staticData:
type: string
nullable: true
example: '{ iterationId: 2 }'
tags:
type: array
items:
$ref: './tag.yml'
readOnly: true

View file

@ -0,0 +1,11 @@
type: object
properties:
data:
type: array
items:
$ref: './workflow.yml'
nextCursor:
type: string
description: Paginate through workflows by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View file

@ -0,0 +1,24 @@
type: object
additionalProperties: false
properties:
saveExecutionProgress:
type: boolean
saveManualExecutions:
type: boolean
saveDataErrorExecution:
type: string
enum: ['all', 'none']
saveDataSuccessExecution:
type: string
enum: ['all', 'none']
executionTimeout:
type: number
example: 3600
maxLength: 3600
errorWorkflow:
type: string
example: 10
description: The ID of the workflow that contains the error trigger node.
timezone:
type: string
example: America/New_York

View file

@ -0,0 +1,303 @@
import express = require('express');
import { FindManyOptions, In } from 'typeorm';
import { ActiveWorkflowRunner, Db } from '../../../..';
import config = require('../../../../../config');
import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity';
import { InternalHooksManager } from '../../../../InternalHooksManager';
import { externalHooks } from '../../../../Server';
import { replaceInvalidCredentials } from '../../../../WorkflowHelpers';
import { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service';
import {
getWorkflowById,
getSharedWorkflow,
setWorkflowAsActive,
setWorkflowAsInactive,
updateWorkflow,
hasStartNode,
getStartNode,
getWorkflows,
getSharedWorkflows,
getWorkflowsCount,
createWorkflow,
getWorkflowIdsViaTags,
parseTagNames,
} from './workflows.service';
export = {
createWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Create, res: express.Response): Promise<express.Response> => {
let workflow = req.body;
workflow.active = false;
// if the workflow does not have a start node, add it.
if (!hasStartNode(workflow)) {
workflow.nodes.push(getStartNode());
}
const role = await getWorkflowOwnerRole();
await replaceInvalidCredentials(workflow);
workflow = await createWorkflow(workflow, req.user, role);
await externalHooks.run('workflow.afterCreate', [workflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, workflow, true);
return res.json(workflow);
},
],
deleteWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({
message: 'Not Found',
});
}
const workflowRunner = ActiveWorkflowRunner.getInstance();
if (sharedWorkflow.workflow.active) {
// deactivate before deleting
await workflowRunner.remove(id.toString());
}
await Db.collections.Workflow.delete(id);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, id.toString(), true);
await externalHooks.run('workflow.afterDelete', [id.toString()]);
return res.json(sharedWorkflow.workflow);
},
],
getWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({
message: 'Not Found',
});
}
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void InternalHooksManager.getInstance().onUserRetrievedWorkflow(telemetryData);
return res.json(sharedWorkflow.workflow);
},
],
getWorkflows: [
authorize(['owner', 'member']),
validCursor,
async (req: WorkflowRequest.GetAll, res: express.Response): Promise<express.Response> => {
const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query;
let workflows: WorkflowEntity[];
let count: number;
const query: FindManyOptions<WorkflowEntity> = {
skip: offset,
take: limit,
where: {
...(active !== undefined && { active }),
},
...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }),
};
if (isInstanceOwner(req.user)) {
if (tags) {
const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
Object.assign(query.where, { id: In(workflowIds) });
}
workflows = await getWorkflows(query);
count = await getWorkflowsCount(query);
} else {
const options: { workflowIds?: number[] } = {};
if (tags) {
options.workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags));
}
const sharedWorkflows = await getSharedWorkflows(req.user, options);
if (!sharedWorkflows.length) {
return res.status(200).json({
data: [],
nextCursor: null,
});
}
const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId);
Object.assign(query.where, { id: In(workflowsIds) });
workflows = await getWorkflows(query);
count = await getWorkflowsCount(query);
}
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void InternalHooksManager.getInstance().onUserRetrievedAllWorkflows(telemetryData);
return res.json({
data: workflows,
nextCursor: encodeNextCursor({
offset,
limit,
numberOfTotalRecords: count,
}),
});
},
],
updateWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Update, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const updateData = new WorkflowEntity();
Object.assign(updateData, req.body);
const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({
message: 'Not Found',
});
}
// if the workflow does not have a start node, add it.
// else there is nothing you can do in IU
if (!hasStartNode(updateData)) {
updateData.nodes.push(getStartNode());
}
// check credentials for old format
await replaceInvalidCredentials(updateData);
const workflowRunner = ActiveWorkflowRunner.getInstance();
if (sharedWorkflow.workflow.active) {
// When workflow gets saved always remove it as the triggers could have been
// changed and so the changes would not take effect
await workflowRunner.remove(id.toString());
}
await updateWorkflow(sharedWorkflow.workflowId, updateData);
if (sharedWorkflow.workflow.active) {
try {
await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'update');
} catch (error) {
// todo
// remove the type assertion
const errorObject = error as unknown as { message: string };
return res.status(400).json({ error: errorObject.message });
}
}
const updatedWorkflow = await getWorkflowById(sharedWorkflow.workflowId);
await externalHooks.run('workflow.afterUpdate', [updateData]);
void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updateData, true);
return res.json(updatedWorkflow);
},
],
activateWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({
message: 'Not Found',
});
}
const workflowRunner = ActiveWorkflowRunner.getInstance();
if (!sharedWorkflow.workflow.active) {
try {
await workflowRunner.add(sharedWorkflow.workflowId.toString(), 'activate');
} catch (error) {
// todo
// remove the type assertion
const errorObject = error as unknown as { message: string };
return res.status(400).json({ error: errorObject.message });
}
// change the status to active in the DB
await setWorkflowAsActive(sharedWorkflow.workflow);
sharedWorkflow.workflow.active = true;
return res.json(sharedWorkflow.workflow);
}
// nothing to do as the wokflow is already active
return res.json(sharedWorkflow.workflow);
},
],
deactivateWorkflow: [
authorize(['owner', 'member']),
async (req: WorkflowRequest.Activate, res: express.Response): Promise<express.Response> => {
const { id } = req.params;
const sharedWorkflow = await getSharedWorkflow(req.user, id.toString());
if (!sharedWorkflow) {
// user trying to access a workflow he does not own
// or workflow does not exist
return res.status(404).json({
message: 'Not Found',
});
}
const workflowRunner = ActiveWorkflowRunner.getInstance();
if (sharedWorkflow.workflow.active) {
await workflowRunner.remove(sharedWorkflow.workflowId.toString());
await setWorkflowAsInactive(sharedWorkflow.workflow);
sharedWorkflow.workflow.active = false;
return res.json(sharedWorkflow.workflow);
}
// nothing to do as the wokflow is already inactive
return res.json(sharedWorkflow.workflow);
},
],
};

View file

@ -0,0 +1,147 @@
import { intersection } from 'lodash';
import type { INode } from 'n8n-workflow';
import { FindManyOptions, In, UpdateResult } from 'typeorm';
import { User } from '../../../../databases/entities/User';
import { WorkflowEntity } from '../../../../databases/entities/WorkflowEntity';
import { Db } from '../../../..';
import { SharedWorkflow } from '../../../../databases/entities/SharedWorkflow';
import { isInstanceOwner } from '../users/users.service';
import { Role } from '../../../../databases/entities/Role';
export async function getSharedWorkflowIds(user: User): Promise<number[]> {
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
where: {
user,
},
});
return sharedWorkflows.map((workflow) => workflow.workflowId);
}
export async function getSharedWorkflow(
user: User,
workflowId?: string | undefined,
): Promise<SharedWorkflow | undefined> {
const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({
where: {
...(!isInstanceOwner(user) && { user }),
...(workflowId && { workflow: { id: workflowId } }),
},
relations: ['workflow'],
});
return sharedWorkflow;
}
export async function getSharedWorkflows(
user: User,
options: {
relations?: string[];
workflowIds?: number[];
},
): Promise<SharedWorkflow[]> {
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
where: {
...(!isInstanceOwner(user) && { user }),
...(options.workflowIds && { workflow: { id: In(options.workflowIds) } }),
},
...(options.relations && { relations: options.relations }),
});
return sharedWorkflows;
}
export async function getWorkflowById(id: number): Promise<WorkflowEntity | undefined> {
const workflow = await Db.collections.Workflow.findOne({
where: {
id,
},
});
return workflow;
}
/**
* Returns the workflow IDs that have certain tags.
* Intersection! e.g. workflow needs to have all provided tags.
*/
export async function getWorkflowIdsViaTags(tags: string[]): Promise<number[]> {
const dbTags = await Db.collections.Tag.find({
where: {
name: In(tags),
},
relations: ['workflows'],
});
const workflowIdsPerTag = dbTags.map((tag) => tag.workflows.map((workflow) => workflow.id));
return intersection(...workflowIdsPerTag);
}
export async function createWorkflow(
workflow: WorkflowEntity,
user: User,
role: Role,
): Promise<WorkflowEntity> {
let savedWorkflow: unknown;
const newWorkflow = new WorkflowEntity();
Object.assign(newWorkflow, workflow);
await Db.transaction(async (transactionManager) => {
savedWorkflow = await transactionManager.save<WorkflowEntity>(newWorkflow);
const newSharedWorkflow = new SharedWorkflow();
Object.assign(newSharedWorkflow, {
role,
user,
workflow: savedWorkflow,
});
await transactionManager.save<SharedWorkflow>(newSharedWorkflow);
});
return savedWorkflow as WorkflowEntity;
}
export async function setWorkflowAsActive(workflow: WorkflowEntity): Promise<UpdateResult> {
return Db.collections.Workflow.update(workflow.id, { active: true, updatedAt: new Date() });
}
export async function setWorkflowAsInactive(workflow: WorkflowEntity): Promise<UpdateResult> {
return Db.collections.Workflow.update(workflow.id, { active: false, updatedAt: new Date() });
}
export async function deleteWorkflow(workflow: WorkflowEntity): Promise<WorkflowEntity> {
return Db.collections.Workflow.remove(workflow);
}
export async function getWorkflows(
options: FindManyOptions<WorkflowEntity>,
): Promise<WorkflowEntity[]> {
const workflows = await Db.collections.Workflow.find(options);
return workflows;
}
export async function getWorkflowsCount(options: FindManyOptions<WorkflowEntity>): Promise<number> {
const count = await Db.collections.Workflow.count(options);
return count;
}
export async function updateWorkflow(
workflowId: number,
updateData: WorkflowEntity,
): Promise<UpdateResult> {
return Db.collections.Workflow.update(workflowId, updateData);
}
export function hasStartNode(workflow: WorkflowEntity): boolean {
return !(
!workflow.nodes.length || !workflow.nodes.find((node) => node.type === 'n8n-nodes-base.start')
);
}
export function getStartNode(): INode {
return {
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [240, 300],
};
}
export function parseTagNames(tags: string): string[] {
return tags.split(',').map((tag) => tag.trim());
}

View file

@ -0,0 +1,59 @@
---
openapi: 3.0.0
info:
title: n8n Public API
description: n8n Public API
termsOfService: https://n8n.io/legal/terms
contact:
email: hello@n8n.io
license:
name: Sustainable Use License
url: https://github.com/n8n-io/n8n/blob/master/packages/cli/LICENSE.md
version: 1.0.0
externalDocs:
description: n8n API documentation
url: https://docs.n8n.io/api/
servers:
- url: /api/v1
tags:
- name: Execution
description: Operations about executions
- name: Workflow
description: Operations about workflows
- name: Credential
description: Operations about credentials
paths:
/credentials:
$ref: './handlers/credentials/spec/paths/credentials.yml'
/credentials/{id}:
$ref: './handlers/credentials/spec/paths/credentials.id.yml'
/credentials/schema/{credentialTypeName}:
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
/executions:
$ref: './handlers/executions/spec/paths/executions.yml'
/executions/{id}:
$ref: './handlers/executions/spec/paths/executions.id.yml'
/workflows:
$ref: './handlers/workflows/spec/paths/workflows.yml'
/workflows/{id}:
$ref: './handlers/workflows/spec/paths/workflows.id.yml'
/workflows/{id}/activate:
$ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
/workflows/{id}/deactivate:
$ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
components:
schemas:
$ref: './shared/spec/schemas/_index.yml'
responses:
$ref: './shared/spec/responses/_index.yml'
parameters:
$ref: './shared/spec/parameters/_index.yml'
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-N8N-API-KEY
security:
- ApiKeyAuth: []

View file

@ -0,0 +1,40 @@
/* eslint-disable consistent-return */
import { RequestHandler } from 'express';
import { PaginatatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service';
type Role = 'member' | 'owner';
export const authorize: (role: Role[]) => RequestHandler = (role: Role[]) => (req, res, next) => {
const {
globalRole: { name: userRole },
} = req.user as { globalRole: { name: Role } };
if (role.includes(userRole)) {
return next();
}
return res.status(403).json({
message: 'Forbidden',
});
};
// @ts-ignore
export const validCursor: RequestHandler = (req: PaginatatedRequest, res, next) => {
if (req.query.cursor) {
const { cursor } = req.query;
try {
const paginationData = decodeCursor(cursor);
if ('offset' in paginationData) {
req.query.offset = paginationData.offset;
req.query.limit = paginationData.limit;
} else {
req.query.lastId = paginationData.lastId;
req.query.limit = paginationData.limit;
}
} catch (error) {
return res.status(400).json({
message: 'An invalid cursor was provided',
});
}
}
next();
};

View file

@ -0,0 +1,46 @@
import {
CursorPagination,
OffsetPagination,
PaginationCursorDecoded,
PaginationOffsetDecoded,
} from '../../../types';
export const decodeCursor = (cursor: string): PaginationOffsetDecoded | PaginationCursorDecoded => {
return JSON.parse(Buffer.from(cursor, 'base64').toString()) as
| PaginationCursorDecoded
| PaginationOffsetDecoded;
};
const encodeOffSetPagination = (pagination: OffsetPagination): string | null => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (pagination.numberOfTotalRecords > pagination.offset + pagination.limit) {
return Buffer.from(
JSON.stringify({
limit: pagination.limit,
offset: pagination.offset + pagination.limit,
}),
).toString('base64');
}
return null;
};
const encodeCursorPagination = (pagination: CursorPagination): string | null => {
if (pagination.numberOfNextRecords) {
return Buffer.from(
JSON.stringify({
lastId: pagination.lastId,
limit: pagination.limit,
}),
).toString('base64');
}
return null;
};
export const encodeNextCursor = (
pagination: OffsetPagination | CursorPagination,
): string | null => {
if ('offset' in pagination) {
return encodeOffSetPagination(pagination);
}
return encodeCursorPagination(pagination);
};

View file

@ -0,0 +1,10 @@
Cursor:
$ref: './cursor.yml'
Limit:
$ref: './limit.yml'
ExecutionId:
$ref: '../../../handlers/executions/spec/schemas/parameters/executionId.yml'
WorkflowId:
$ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml'
IncludeData:
$ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml'

View file

@ -0,0 +1,7 @@
name: cursor
in: query
description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request's response. Default value fetches the first "page" of the collection. See pagination for more detail.
required: false
style: form
schema:
type: string

View file

@ -0,0 +1,9 @@
name: limit
in: query
description: The maximum number of items to return.
required: false
schema:
type: number
example: 100
default: 100
maximum: 250

View file

@ -0,0 +1,6 @@
NotFound:
$ref: './notFound.yml'
Unauthorized:
$ref: './unauthorized.yml'
BadRequest:
$ref: './badRequest.yml'

View file

@ -0,0 +1 @@
description: The request is invalid or provides malformed data.

View file

@ -0,0 +1 @@
description: The specified resource was not found.

View file

@ -0,0 +1 @@
description: Unauthorized

View file

@ -0,0 +1,20 @@
Error:
$ref: './error.yml'
Execution:
$ref: './../../../handlers/executions/spec/schemas/execution.yml'
Node:
$ref: './../../../handlers/workflows/spec/schemas/node.yml'
Tag:
$ref: './../../../handlers/workflows/spec/schemas/tag.yml'
Workflow:
$ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
WorkflowSettings:
$ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
ExecutionList:
$ref: './../../../handlers/executions/spec/schemas/executionList.yml'
WorkflowList:
$ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
Credential:
$ref: './../../../handlers/credentials/spec/schemas/credential.yml'
CredentialType:
$ref: './../../../handlers/credentials/spec/schemas/credentialType.yml'

View file

@ -0,0 +1,10 @@
required:
- message
type: object
properties:
code:
type: string
message:
type: string
description:
type: string

View file

@ -130,7 +130,6 @@ export function sendErrorResponse(res: Response, error: ResponseError) {
// @ts-ignore
response.stack = error.stack;
}
res.status(httpStatusCode).json(response);
}
@ -147,12 +146,13 @@ const isUniqueConstraintError = (error: Error) =>
* @returns
*/
export function send(processFunction: (req: Request, res: Response) => Promise<any>) {
export function send(processFunction: (req: Request, res: Response) => Promise<any>, raw = false) {
// eslint-disable-next-line consistent-return
return async (req: Request, res: Response) => {
try {
const data = await processFunction(req, res);
sendSuccessResponse(res, data);
sendSuccessResponse(res, data, raw);
} catch (error) {
if (error instanceof Error && isUniqueConstraintError(error)) {
error.message = 'There is already an entry with this name';

View file

@ -53,7 +53,7 @@ import clientOAuth2 from 'client-oauth2';
import clientOAuth1, { RequestOptions } from 'oauth-1.0a';
import csrf from 'csrf';
import requestPromise, { OptionsWithUrl } from 'request-promise-native';
import { createHmac } from 'crypto';
import { createHmac, randomBytes } from 'crypto';
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
import { compare } from 'bcryptjs';
@ -102,6 +102,7 @@ import {
ICredentialsDb,
ICredentialsOverwrite,
ICustomRequest,
IDiagnosticInfo,
IExecutionFlattedDb,
IExecutionFlattedResponse,
IExecutionPushResponse,
@ -110,7 +111,6 @@ import {
IExecutionsStopData,
IExecutionsSummary,
IExternalHooksClass,
IDiagnosticInfo,
IN8nUISettings,
IPackageVersions,
ITagWithCountDb,
@ -146,11 +146,13 @@ import { userManagementRouter } from './UserManagement';
import { resolveJwt } from './UserManagement/auth/jwt';
import { User } from './databases/entities/User';
import type {
AuthenticatedRequest,
CredentialRequest,
ExecutionRequest,
WorkflowRequest,
NodeParameterOptionsRequest,
OAuthRequest,
TagsRequest,
WorkflowRequest,
} from './requests';
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
@ -162,6 +164,7 @@ import {
isEmailSetUp,
isUserManagementEnabled,
} from './UserManagement/UserManagementHelper';
import { loadPublicApiVersions } from './PublicApi';
require('body-parser-xml')(bodyParser);
@ -210,6 +213,8 @@ class App {
restEndpoint: string;
publicApiEndpoint: string;
frontendSettings: IN8nUISettings;
protocol: string;
@ -234,14 +239,15 @@ class App {
this.defaultWorkflowName = config.getEnv('workflows.defaultName');
this.defaultCredentialsName = config.getEnv('credentials.defaultName');
this.saveDataErrorExecution = config.getEnv('executions.saveDataOnError');
this.saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess');
this.saveManualExecutions = config.getEnv('executions.saveDataManualExecutions');
this.executionTimeout = config.getEnv('executions.timeout');
this.maxExecutionTimeout = config.getEnv('executions.maxTimeout');
this.payloadSizeMax = config.getEnv('endpoints.payloadSizeMax');
this.timezone = config.getEnv('generic.timezone');
this.restEndpoint = config.getEnv('endpoints.rest');
this.saveDataErrorExecution = config.get('executions.saveDataOnError');
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess');
this.saveManualExecutions = config.get('executions.saveDataManualExecutions');
this.executionTimeout = config.get('executions.timeout');
this.maxExecutionTimeout = config.get('executions.maxTimeout');
this.payloadSizeMax = config.get('endpoints.payloadSizeMax');
this.timezone = config.get('generic.timezone');
this.restEndpoint = config.get('endpoints.rest');
this.publicApiEndpoint = config.get('publicApi.path');
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
this.testWebhooks = TestWebhooks.getInstance();
@ -310,6 +316,11 @@ class App {
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
smtpSetup: isEmailSetUp(),
},
publicApi: {
enabled: config.getEnv('publicApi.disabled') === false,
latestVersion: 1,
path: config.getEnv('publicApi.path'),
},
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'),
logLevel: config.getEnv('logs.level'),
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
@ -373,6 +384,9 @@ class App {
this.endpointWebhookTest,
this.endpointPresetCredentials,
];
if (!config.getEnv('publicApi.disabled')) {
ignoredEndpoints.push(this.publicApiEndpoint);
}
// eslint-disable-next-line prefer-spread
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
@ -484,8 +498,9 @@ class App {
// eslint-disable-next-line no-inner-declarations
function isTenantAllowed(decodedToken: object): boolean {
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '')
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') {
return true;
}
for (const [k, v] of Object.entries(decodedToken)) {
if (k === jwtNamespace) {
@ -543,6 +558,15 @@ class App {
});
}
// ----------------------------------------
// Public API
// ----------------------------------------
if (!config.getEnv('publicApi.disabled')) {
const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(this.publicApiEndpoint);
this.app.use(...apiRouters);
this.frontendSettings.publicApi.latestVersion = apiLatestVersion;
}
// Parse cookies for easier access
this.app.use(cookieParser());
@ -786,7 +810,7 @@ class App {
}
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow);
void InternalHooksManager.getInstance().onWorkflowCreated(req.user.id, newWorkflow, false);
const { id, ...rest } = savedWorkflow;
@ -1076,7 +1100,11 @@ class App {
}
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
void InternalHooksManager.getInstance().onWorkflowSaved(req.user.id, updatedWorkflow);
void InternalHooksManager.getInstance().onWorkflowSaved(
req.user.id,
updatedWorkflow,
false,
);
if (updatedWorkflow.active) {
// When the workflow is supposed to be active add it again
@ -1144,7 +1172,7 @@ class App {
await Db.collections.Workflow.delete(workflowId);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId);
void InternalHooksManager.getInstance().onWorkflowDeleted(req.user.id, workflowId, false);
await this.externalHooks.run('workflow.afterDelete', [workflowId]);
return true;

View file

@ -123,6 +123,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
resetPasswordTokenExpiration,
createdAt,
updatedAt,
apiKey,
...sanitizedUser
} = user;
if (withoutKeys) {

View file

@ -6,7 +6,7 @@ import { Response } from 'express';
import { createHash } from 'crypto';
import { Db } from '../..';
import { AUTH_COOKIE_NAME } from '../../constants';
import { JwtToken, JwtPayload } from '../Interfaces';
import { JwtPayload, JwtToken } from '../Interfaces';
import { User } from '../../databases/entities/User';
import * as config from '../../../config';

View file

@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable import/no-cycle */
import express from 'express';
import validator from 'validator';
import { LoggerProxy as Logger } from 'n8n-workflow';
import { randomBytes } from 'crypto';
import { Db, InternalHooksManager, ResponseHelper } from '../..';
import { issueCookie } from '../auth/jwt';
import { N8nApp, PublicUser } from '../Interfaces';
@ -149,4 +151,58 @@ export function meNamespace(this: N8nApp): void {
return { success: true };
}),
);
/**
* Creates an API Key
*/
this.app.post(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
const apiKey = `n8n_api_${randomBytes(40).toString('hex')}`;
await Db.collections.User.update(req.user.id, {
apiKey,
});
const telemetryData = {
user_id: req.user.id,
public_api: false,
};
void InternalHooksManager.getInstance().onApiKeyCreated(telemetryData);
return { apiKey };
}),
);
/**
* Deletes an API Key
*/
this.app.delete(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
await Db.collections.User.update(req.user.id, {
apiKey: null,
});
const telemetryData = {
user_id: req.user.id,
public_api: false,
};
void InternalHooksManager.getInstance().onApiKeyDeleted(telemetryData);
return { success: true };
}),
);
/**
* Get an API Key
*/
this.app.get(
`/${this.restEndpoint}/me/api-key`,
ResponseHelper.send(async (req: AuthenticatedRequest) => {
return { apiKey: req.user.apiKey };
}),
);
}

View file

@ -89,6 +89,7 @@ export function passwordResetNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: user.id,
message_type: 'Reset password',
public_api: false,
});
if (error instanceof Error) {
throw new ResponseHelper.ResponseError(
@ -103,6 +104,7 @@ export function passwordResetNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: id,
message_type: 'Reset password',
public_api: false,
});
void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({

View file

@ -156,6 +156,7 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserInvite({
user_id: req.user.id,
target_user_id: Object.values(createUsers) as string[],
public_api: false,
});
} catch (error) {
Logger.error('Failed to create user shells', { userShells: createUsers });
@ -193,11 +194,13 @@ export function usersNamespace(this: N8nApp): void {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
user_id: id!,
message_type: 'New user invite',
public_api: false,
});
} else {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: req.user.id,
message_type: 'New user invite',
public_api: false,
});
Logger.error('Failed to send email', {
userId: req.user.id,
@ -378,6 +381,7 @@ export function usersNamespace(this: N8nApp): void {
*/
this.app.delete(
`/${this.restEndpoint}/users/:id`,
// @ts-ignore
ResponseHelper.send(async (req: UserRequest.Delete) => {
const { id: idToDelete } = req.params;
@ -472,7 +476,7 @@ export function usersNamespace(this: N8nApp): void {
telemetryData.migration_user_id = transferId;
}
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData);
void InternalHooksManager.getInstance().onUserDeletion(req.user.id, telemetryData, false);
return { success: true };
}),
@ -538,6 +542,7 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onEmailFailed({
user_id: req.user.id,
message_type: 'Resend invite',
public_api: false,
});
Logger.error('Failed to send email', {
email: reinvitee.email,
@ -554,11 +559,13 @@ export function usersNamespace(this: N8nApp): void {
void InternalHooksManager.getInstance().onUserReinvite({
user_id: req.user.id,
target_user_id: reinvitee.id,
public_api: false,
});
void InternalHooksManager.getInstance().onUserTransactionalEmail({
user_id: reinvitee.id,
message_type: 'Resend invite',
public_api: false,
});
return { success: true };

View file

@ -10,8 +10,6 @@ import express from 'express';
import { readFileSync } from 'fs';
import { getConnectionManager } from 'typeorm';
import bodyParser from 'body-parser';
// eslint-disable-next-line import/no-extraneous-dependencies, @typescript-eslint/no-unused-vars
import _ from 'lodash';
import compression from 'compression';
// eslint-disable-next-line import/no-extraneous-dependencies

View file

@ -137,6 +137,10 @@ export class User {
this.updatedAt = new Date();
}
@Column({ type: String, nullable: true })
@Index({ unique: true })
apiKey?: string | null;
/**
* Whether the user is pending setup completion.
*/

View file

@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config from '../../../../config';
export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
name = 'AddAPIKeyColumn1652905585850';
public async up(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query(
'ALTER TABLE `' + tablePrefix + 'user` ADD COLUMN `apiKey` VARCHAR(255)',
);
await queryRunner.query(
'CREATE UNIQUE INDEX `UQ_' +
tablePrefix +
'ie0zomxves9w3p774drfrkxtj5` ON `' +
tablePrefix +
'user` (`apiKey`)',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.get('database.tablePrefix');
await queryRunner.query(
'DROP INDEX `IDX_81fc04c8a17de15835713505e4` ON `' + tablePrefix + 'execution_entity`',
);
await queryRunner.query('ALTER TABLE `' + tablePrefix + 'user` DROP COLUMN `apiKey`');
}
}

View file

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

View file

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import config from '../../../../config';
export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
name = 'AddAPIKeyColumn1652905585850';
public async up(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`ALTER TABLE ${tablePrefix}user ADD COLUMN "apiKey" VARCHAR(255)`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON ${tablePrefix}user ("apiKey")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
let tablePrefix = config.getEnv('database.tablePrefix');
const schema = config.getEnv('database.postgresdb.schema');
if (schema) {
tablePrefix = schema + '.' + tablePrefix;
}
await queryRunner.query(`DROP INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5"`);
await queryRunner.query(`ALTER TABLE ${tablePrefix}user DROP COLUMN "apiKey"`);
}
}

View file

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

View file

@ -0,0 +1,54 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as config from '../../../../config';
import { logMigrationEnd, logMigrationStart } from '../../utils/migrationHelpers';
export class AddAPIKeyColumn1652905585850 implements MigrationInterface {
name = 'AddAPIKeyColumn1652905585850';
async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, "apiKey" varchar, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "${tablePrefix}user"`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "${tablePrefix}user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}ie0zomxves9w3p774drfrkxtj5" ON "${tablePrefix}user" ("apiKey")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
logMigrationEnd(this.name);
}
async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');
await queryRunner.query('PRAGMA foreign_keys=OFF');
await queryRunner.query(`ALTER TABLE "${tablePrefix}user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "${tablePrefix}user" ("id" varchar PRIMARY KEY NOT NULL, "email" varchar(255), "firstName" varchar(32), "lastName" varchar(32), "password" varchar, "resetPasswordToken" varchar, "resetPasswordTokenExpiration" integer DEFAULT NULL, "personalizationAnswers" text, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "globalRoleId" integer NOT NULL, "settings" text, CONSTRAINT "FK_${tablePrefix}f0609be844f9200ff4365b1bb3d" FOREIGN KEY ("globalRoleId") REFERENCES "${tablePrefix}role" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`,
);
await queryRunner.query(
`INSERT INTO "${tablePrefix}user"("id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings") SELECT "id", "email", "firstName", "lastName", "password", "resetPasswordToken", "resetPasswordTokenExpiration", "personalizationAnswers", "createdAt", "updatedAt", "globalRoleId", "settings" FROM "temporary_user"`,
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
await queryRunner.query(
`CREATE UNIQUE INDEX "UQ_${tablePrefix}e12875dfb3b1d92d7d7c5377e2" ON "${tablePrefix}user" ("email")`,
);
await queryRunner.query('PRAGMA foreign_keys=ON');
}
}

View file

@ -13,6 +13,7 @@ import { AddExecutionEntityIndexes1644421939510 } from './1644421939510-AddExecu
import { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -28,6 +29,7 @@ const sqliteMigrations = [
CreateUserManagement1646992772331,
LowerCaseUserEmail1648740597343,
AddUserSettings1652367743993,
AddAPIKeyColumn1652905585850,
];
export { sqliteMigrations };

View file

@ -11,8 +11,10 @@ import {
} from 'n8n-workflow';
import { User } from './databases/entities/User';
import { Role } from './databases/entities/Role';
import type { IExecutionDeleteFilter, IWorkflowDb } from '.';
import type { PublicUser } from './UserManagement/Interfaces';
import * as UserManagementMailer from './UserManagement/email/UserManagementMailer';
export type AuthlessRequest<
RouteParams = {},
@ -26,7 +28,11 @@ export type AuthenticatedRequest<
ResponseBody = {},
RequestBody = {},
RequestQuery = {},
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & { user: User };
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
user: User;
mailer?: UserManagementMailer.UserManagementMailer;
globalMemberRole?: Role;
};
// ----------------------------------
// /workflows
@ -196,7 +202,19 @@ export declare namespace UserRequest {
{ inviterId?: string; inviteeId?: string }
>;
export type Delete = AuthenticatedRequest<{ id: string }, {}, {}, { transferId?: string }>;
export type Delete = AuthenticatedRequest<
{ id: string; email: string; identifier: string },
{},
{},
{ transferId?: string; includeRole: boolean }
>;
export type Get = AuthenticatedRequest<
{ id: string; email: string; identifier: string },
{},
{},
{ limit?: number; offset?: number; cursor?: string; includeRole?: boolean }
>;
export type Reinvite = AuthenticatedRequest<{ id: string }>;

View file

@ -18,7 +18,7 @@ let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@ -68,6 +68,7 @@ test('POST /login should log user in', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -81,6 +82,7 @@ test('POST /login should log user in', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
@ -146,6 +148,7 @@ test('GET /login should return logged-in owner shell', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -159,6 +162,7 @@ test('GET /login should return logged-in owner shell', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@ -181,6 +185,7 @@ test('GET /login should return logged-in member shell', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -194,6 +199,7 @@ test('GET /login should return logged-in member shell', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@ -216,6 +222,7 @@ test('GET /login should return logged-in owner', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -229,6 +236,7 @@ test('GET /login should return logged-in owner', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();
@ -251,6 +259,7 @@ test('GET /login should return logged-in member', async () => {
personalizationAnswers,
globalRole,
resetPasswordToken,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -264,6 +273,7 @@ test('GET /login should return logged-in member', async () => {
expect(globalRole).toBeDefined();
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeUndefined();

View file

@ -17,7 +17,7 @@ let testDbName = '';
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({
app = await utils.initTestServer({
applyAuth: true,
endpointGroups: ['me', 'auth', 'owner', 'users'],
});

View file

@ -14,7 +14,7 @@ let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;

View file

@ -19,7 +19,7 @@ let globalMemberRole: Role;
let saveCredential: SaveCredentialFunction;
beforeAll(async () => {
app = utils.initTestServer({
app = await utils.initTestServer({
endpointGroups: ['credentials'],
applyAuth: true,
});

View file

@ -7,7 +7,13 @@ import * as utils from './shared/utils';
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
import { Db } from '../../src';
import type { Role } from '../../src/databases/entities/Role';
import { randomValidPassword, randomEmail, randomName, randomString } from './shared/random';
import {
randomApiKey,
randomEmail,
randomName,
randomString,
randomValidPassword,
} from './shared/random';
import * as testDb from './shared/testDb';
jest.mock('../../src/telemetry');
@ -18,7 +24,7 @@ let globalOwnerRole: Role;
let globalMemberRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@ -55,6 +61,7 @@ describe('Owner shell', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -67,6 +74,7 @@ describe('Owner shell', () => {
expect(isPending).toBe(true);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@ -88,6 +96,7 @@ describe('Owner shell', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -100,6 +109,7 @@ describe('Owner shell', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const storedOwnerShell = await Db.collections.User.findOneOrFail(id);
@ -175,6 +185,50 @@ describe('Owner shell', () => {
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
}
});
test('POST /me/api-key should create an api key', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerShellAgent.post('/me/api-key');
expect(response.statusCode).toBe(200);
expect(response.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull();
const storedShellOwner = await Db.collections.User.findOneOrFail({
where: { email: IsNull() },
});
expect(storedShellOwner.apiKey).toEqual(response.body.data.apiKey);
});
test('GET /me/api-key should fetch the api key', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerShellAgent.get('/me/api-key');
expect(response.statusCode).toBe(200);
expect(response.body.data.apiKey).toEqual(ownerShell.apiKey);
});
test('DELETE /me/api-key should fetch the api key', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerShellAgent = utils.createAgent(app, { auth: true, user: ownerShell });
const response = await authOwnerShellAgent.delete('/me/api-key');
expect(response.statusCode).toBe(200);
const storedShellOwner = await Db.collections.User.findOneOrFail({
where: { email: IsNull() },
});
expect(storedShellOwner.apiKey).toBeNull();
});
});
describe('Member', () => {
@ -209,6 +263,7 @@ describe('Member', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -221,6 +276,7 @@ describe('Member', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@ -242,6 +298,7 @@ describe('Member', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -254,6 +311,7 @@ describe('Member', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('member');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const storedMember = await Db.collections.User.findOneOrFail(id);
@ -335,6 +393,53 @@ describe('Member', () => {
expect(storedAnswers).toEqual(validPayload);
}
});
test('POST /me/api-key should create an api key', async () => {
const member = await testDb.createUser({
globalRole: globalMemberRole,
apiKey: randomApiKey(),
});
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent.post('/me/api-key');
expect(response.statusCode).toBe(200);
expect(response.body.data.apiKey).toBeDefined();
expect(response.body.data.apiKey).not.toBeNull();
const storedMember = await Db.collections.User.findOneOrFail(member.id);
expect(storedMember.apiKey).toEqual(response.body.data.apiKey);
});
test('GET /me/api-key should fetch the api key', async () => {
const member = await testDb.createUser({
globalRole: globalMemberRole,
apiKey: randomApiKey(),
});
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent.get('/me/api-key');
expect(response.statusCode).toBe(200);
expect(response.body.data.apiKey).toEqual(member.apiKey);
});
test('DELETE /me/api-key should fetch the api key', async () => {
const member = await testDb.createUser({
globalRole: globalMemberRole,
apiKey: randomApiKey(),
});
const authMemberAgent = utils.createAgent(app, { auth: true, user: member });
const response = await authMemberAgent.delete('/me/api-key');
expect(response.statusCode).toBe(200);
const storedMember = await Db.collections.User.findOneOrFail(member.id);
expect(storedMember.apiKey).toBeNull();
});
});
describe('Owner', () => {
@ -364,6 +469,7 @@ describe('Owner', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -376,6 +482,7 @@ describe('Owner', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
});
test('PATCH /me should succeed with valid inputs', async () => {
@ -397,6 +504,7 @@ describe('Owner', () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -409,6 +517,7 @@ describe('Owner', () => {
expect(isPending).toBe(false);
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const storedOwner = await Db.collections.User.findOneOrFail(id);

View file

@ -20,7 +20,7 @@ let testDbName = '';
let globalOwnerRole: Role;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@ -66,6 +66,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
password,
resetPasswordToken,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -78,6 +79,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
expect(resetPasswordToken).toBeUndefined();
expect(globalRole.name).toBe('owner');
expect(globalRole.scope).toBe('global');
expect(apiKey).toBeUndefined();
const storedOwner = await Db.collections.User.findOneOrFail(id);
expect(storedOwner.password).not.toBe(newOwnerData.password);

View file

@ -24,7 +24,7 @@ let globalMemberRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;

View file

@ -0,0 +1,411 @@
import express from 'express';
import { UserSettings } from 'n8n-core';
import { Db } from '../../../src';
import { randomApiKey, randomName, randomString } from '../shared/random';
import * as utils from '../shared/utils';
import type { CredentialPayload, SaveCredentialFunction } from '../shared/types';
import type { Role } from '../../../src/databases/entities/Role';
import type { User } from '../../../src/databases/entities/User';
import * as testDb from '../shared/testDb';
import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
jest.mock('../../../src/telemetry');
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let globalMemberRole: Role;
let workflowOwnerRole: Role;
let credentialOwnerRole: Role;
let saveCredential: SaveCredentialFunction;
beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
utils.initConfigFile();
const [
fetchedGlobalOwnerRole,
fetchedGlobalMemberRole,
fetchedWorkflowOwnerRole,
fetchedCredentialOwnerRole,
] = await testDb.getAllRoles();
globalOwnerRole = fetchedGlobalOwnerRole;
globalMemberRole = fetchedGlobalMemberRole;
workflowOwnerRole = fetchedWorkflowOwnerRole;
credentialOwnerRole = fetchedCredentialOwnerRole;
saveCredential = affixRoleToSaveCredential(credentialOwnerRole);
utils.initTestLogger();
utils.initTestTelemetry();
utils.initCredentialsTypes();
});
beforeEach(async () => {
await testDb.truncate(['User', 'SharedCredentials', 'Credentials'], testDbName);
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test('POST /credentials should create credentials', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const payload = {
name: 'test credential',
type: 'githubApi',
data: {
accessToken: 'abcdefghijklmnopqrstuvwxyz',
user: 'test',
server: 'testServer',
},
};
const response = await authOwnerAgent.post('/credentials').send(payload);
expect(response.statusCode).toBe(200);
const { id, name, type } = response.body;
expect(name).toBe(payload.name);
expect(type).toBe(payload.type);
const credential = await Db.collections.Credentials!.findOneOrFail(id);
expect(credential.name).toBe(payload.name);
expect(credential.type).toBe(payload.type);
expect(credential.data).not.toBe(payload.data);
const sharedCredential = await Db.collections.SharedCredentials!.findOneOrFail({
relations: ['user', 'credentials', 'role'],
where: { credentials: credential, user: ownerShell },
});
expect(sharedCredential.role).toEqual(credentialOwnerRole);
expect(sharedCredential.credentials.name).toBe(payload.name);
});
test('POST /credentials should fail with invalid inputs', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
await Promise.all(
INVALID_PAYLOADS.map(async (invalidPayload) => {
const response = await authOwnerAgent.post('/credentials').send(invalidPayload);
expect(response.statusCode === 400 || response.statusCode === 415).toBe(true);
}),
);
});
test('POST /credentials should fail with missing encryption key', async () => {
const mock = jest.spyOn(UserSettings, 'getEncryptionKey');
mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY));
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const response = await authOwnerAgent.post('/credentials').send(credentialPayload());
expect(response.statusCode).toBe(500);
mock.mockRestore();
});
test('DELETE /credentials/:id should delete owned cred for owner', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const savedCredential = await saveCredential(dbCredential(), { user: ownerShell });
const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
const { name, type } = response.body;
expect(name).toBe(savedCredential.name);
expect(type).toBe(savedCredential.type);
const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
expect(deletedCredential).toBeUndefined(); // deleted
const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
expect(deletedSharedCredential).toBeUndefined(); // deleted
});
test('DELETE /credentials/:id should delete non-owned cred for owner', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const member = await testDb.createUser({ globalRole: globalMemberRole });
const savedCredential = await saveCredential(dbCredential(), { user: member });
const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
expect(deletedCredential).toBeUndefined(); // deleted
const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
expect(deletedSharedCredential).toBeUndefined(); // deleted
});
test('DELETE /credentials/:id should delete owned cred for member', async () => {
const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
const authMemberAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const savedCredential = await saveCredential(dbCredential(), { user: member });
const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
const { name, type } = response.body;
expect(name).toBe(savedCredential.name);
expect(type).toBe(savedCredential.type);
const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
expect(deletedCredential).toBeUndefined(); // deleted
const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
expect(deletedSharedCredential).toBeUndefined(); // deleted
});
test('DELETE /credentials/:id should delete owned cred for member but leave others untouched', async () => {
const member1 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
const member2 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
const savedCredential = await saveCredential(dbCredential(), { user: member1 });
const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member1 });
const notToBeChangedCredential2 = await saveCredential(dbCredential(), { user: member2 });
const authMemberAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member1,
});
const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(200);
const { name, type } = response.body;
expect(name).toBe(savedCredential.name);
expect(type).toBe(savedCredential.type);
const deletedCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
expect(deletedCredential).toBeUndefined(); // deleted
const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne({
where: {
credentials: savedCredential,
},
});
expect(deletedSharedCredential).toBeUndefined(); // deleted
await Promise.all(
[notToBeChangedCredential, notToBeChangedCredential2].map(async (credential) => {
const untouchedCredential = await Db.collections.Credentials!.findOne(credential.id);
expect(untouchedCredential).toEqual(credential); // not deleted
const untouchedSharedCredential = await Db.collections.SharedCredentials!.findOne({
where: {
credentials: credential,
},
});
expect(untouchedSharedCredential).toBeDefined(); // not deleted
}),
);
});
test('DELETE /credentials/:id should not delete non-owned cred for member', async () => {
const ownerShell = await testDb.createUserShell(globalOwnerRole);
const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() });
const authMemberAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const savedCredential = await saveCredential(dbCredential(), { user: ownerShell });
const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`);
expect(response.statusCode).toBe(404);
const shellCredential = await Db.collections.Credentials!.findOne(savedCredential.id);
expect(shellCredential).toBeDefined(); // not deleted
const deletedSharedCredential = await Db.collections.SharedCredentials!.findOne();
expect(deletedSharedCredential).toBeDefined(); // not deleted
});
test('DELETE /credentials/:id should fail if cred not found', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const response = await authOwnerAgent.delete('/credentials/123');
expect(response.statusCode).toBe(404);
});
test('GET /credentials/schema/:credentialType should fail due to not found type', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const response = await authOwnerAgent.get('/credentials/schema/testing');
expect(response.statusCode).toBe(404);
});
test('GET /credentials/schema/:credentialType should retrieve credential type', async () => {
let ownerShell = await testDb.createUserShell(globalOwnerRole);
ownerShell = await testDb.addApiKey(ownerShell);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: ownerShell,
});
const response = await authOwnerAgent.get('/credentials/schema/githubApi');
const { additionalProperties, type, properties, required } = response.body;
expect(additionalProperties).toBe(false);
expect(type).toBe('object');
expect(properties.server).toBeDefined();
expect(properties.server.type).toBe('string');
expect(properties.user.type).toBeDefined();
expect(properties.user.type).toBe('string');
expect(properties.accessToken.type).toBeDefined();
expect(properties.accessToken.type).toBe('string');
expect(required).toEqual(expect.arrayContaining(['server', 'user', 'accessToken']));
expect(response.statusCode).toBe(200);
});
const credentialPayload = (): CredentialPayload => ({
name: randomName(),
type: 'githubApi',
data: {
accessToken: randomString(6, 16),
server: randomString(1, 10),
user: randomString(1, 10),
},
});
const dbCredential = () => {
const credential = credentialPayload();
credential.nodesAccess = [{ nodeType: credential.type }];
return credential;
};
const INVALID_PAYLOADS = [
{
type: randomName(),
data: { accessToken: randomString(6, 16) },
},
{
name: randomName(),
data: { accessToken: randomString(6, 16) },
},
{
name: randomName(),
type: randomName(),
},
{
name: randomName(),
type: 'githubApi',
data: {
server: randomName(),
},
},
{},
[],
undefined,
];
function affixRoleToSaveCredential(role: Role) {
return (credentialPayload: CredentialPayload, { user }: { user: User }) =>
testDb.saveCredential(credentialPayload, { user, role });
}

View file

@ -0,0 +1,423 @@
import express = require('express');
import { ActiveWorkflowRunner } from '../../../src';
import config = require('../../../config');
import { Role } from '../../../src/databases/entities/Role';
import { randomApiKey } from '../shared/random';
import * as utils from '../shared/utils';
import * as testDb from '../shared/testDb';
let app: express.Application;
let testDbName = '';
let globalOwnerRole: Role;
let workflowRunner: ActiveWorkflowRunner.ActiveWorkflowRunner;
jest.mock('../../../src/telemetry');
beforeAll(async () => {
app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
globalOwnerRole = await testDb.getGlobalOwnerRole();
utils.initTestTelemetry();
utils.initTestLogger();
// initializing binary manager leave some async operations open
// TODO mockup binary data mannager to avoid error
await utils.initBinaryManager();
await utils.initNodeTypes();
workflowRunner = await utils.initActiveWorkflowRunner();
});
beforeEach(async () => {
// do not combine calls - shared tables must be cleared first and separately
await testDb.truncate(['SharedCredentials', 'SharedWorkflow'], testDbName);
await testDb.truncate(['User', 'Workflow', 'Credentials', 'Execution', 'Settings'], testDbName);
config.set('userManagement.disabled', false);
config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('userManagement.emails.mode', 'smtp');
});
afterEach(async () => {
await workflowRunner.removeAll();
});
afterAll(async () => {
await testDb.terminate(testDbName);
});
test.skip('GET /executions/:executionId should fail due to missing API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.get('/executions/1');
expect(response.statusCode).toBe(401);
});
test.skip('GET /executions/:executionId should fail due to invalid API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
owner.apiKey = 'abcXYZ';
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.get('/executions/1');
expect(response.statusCode).toBe(401);
});
test.skip('GET /executions/:executionId should get an execution', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const workflow = await testDb.createWorkflow({}, owner);
const execution = await testDb.createSuccessfullExecution(workflow);
const response = await authOwnerAgent.get(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body;
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(execution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(execution.workflowId);
expect(waitTill).toBeNull();
});
test.skip('DELETE /executions/:executionId should fail due to missing API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.delete('/executions/1');
expect(response.statusCode).toBe(401);
});
test.skip('DELETE /executions/:executionId should fail due to invalid API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
owner.apiKey = 'abcXYZ';
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.delete('/executions/1');
expect(response.statusCode).toBe(401);
});
test.skip('DELETE /executions/:executionId should delete an execution', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const workflow = await testDb.createWorkflow({}, owner);
const execution = await testDb.createSuccessfullExecution(workflow);
const response = await authOwnerAgent.delete(`/executions/${execution.id}`);
expect(response.statusCode).toBe(200);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body;
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(execution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(execution.workflowId);
expect(waitTill).toBeNull();
});
test.skip('GET /executions should fail due to missing API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(401);
});
test.skip('GET /executions should fail due to invalid API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
owner.apiKey = 'abcXYZ';
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const response = await authOwnerAgent.get('/executions');
expect(response.statusCode).toBe(401);
});
test.skip('GET /executions should retrieve all successfull executions', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const workflow = await testDb.createWorkflow({}, owner);
const successfullExecution = await testDb.createSuccessfullExecution(workflow);
await testDb.createErrorExecution(workflow);
const response = await authOwnerAgent.get(`/executions`).query({
status: 'success',
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(true);
expect(mode).toEqual(successfullExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(successfullExecution.workflowId);
expect(waitTill).toBeNull();
});
test.skip('GET /executions should retrieve all error executions', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const workflow = await testDb.createWorkflow({}, owner);
await testDb.createSuccessfullExecution(workflow);
const errorExecution = await testDb.createErrorExecution(workflow);
const response = await authOwnerAgent.get(`/executions`).query({
status: 'error',
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(false);
expect(mode).toEqual(errorExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(errorExecution.workflowId);
expect(waitTill).toBeNull();
});
test.skip('GET /executions should return all waiting executions', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const workflow = await testDb.createWorkflow({}, owner);
await testDb.createSuccessfullExecution(workflow);
await testDb.createErrorExecution(workflow);
const waitingExecution = await testDb.createWaitingExecution(workflow);
const response = await authOwnerAgent.get(`/executions`).query({
status: 'waiting',
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(1);
expect(response.body.nextCursor).toBe(null);
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = response.body.data[0];
expect(id).toBeDefined();
expect(finished).toBe(false);
expect(mode).toEqual(waitingExecution.mode);
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(waitingExecution.workflowId);
expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000);
});
test.skip('GET /executions should retrieve all executions of specific workflow', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
auth: true,
user: owner,
version: 1,
});
const [workflow, workflow2] = await testDb.createManyWorkflows(2, {}, owner);
const savedExecutions = await testDb.createManyExecutions(
2,
workflow,
// @ts-ignore
testDb.createSuccessfullExecution,
);
// @ts-ignore
await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfullExecution);
const response = await authOwnerAgent.get(`/executions`).query({
workflowId: workflow.id.toString(),
});
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBe(null);
for (const execution of response.body.data) {
const {
id,
finished,
mode,
retryOf,
retrySuccessId,
startedAt,
stoppedAt,
workflowId,
waitTill,
} = execution;
expect(savedExecutions.some((exec) => exec.id === id)).toBe(true);
expect(finished).toBe(true);
expect(mode).toBeDefined();
expect(retrySuccessId).toBeNull();
expect(retryOf).toBeNull();
expect(startedAt).not.toBeNull();
expect(stoppedAt).not.toBeNull();
expect(workflowId).toBe(workflow.id.toString());
expect(waitTill).toBeNull();
}
});

File diff suppressed because it is too large Load diff

View file

@ -2,6 +2,8 @@ import config from '../../../config';
export const REST_PATH_SEGMENT = config.getEnv('endpoints.rest') as Readonly<string>;
export const PUBLIC_API_REST_PATH_SEGMENT = config.getEnv('publicApi.path') as Readonly<string>;
export const AUTHLESS_ENDPOINTS: Readonly<string[]> = [
'healthz',
'metrics',

View file

@ -10,6 +10,10 @@ export function randomString(min: number, max: number) {
return randomBytes(randomInteger / 2).toString('hex');
}
export function randomApiKey() {
return `n8n_api_${randomBytes(20).toString('hex')}`;
}
const chooseRandomly = <T>(array: T[]) => array[Math.floor(Math.random() * array.length)];
const randomDigit = () => Math.floor(Math.random() * 10);
@ -17,7 +21,9 @@ const randomDigit = () => Math.floor(Math.random() * 10);
const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
export const randomValidPassword = () =>
randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) + randomUppercaseLetter() + randomDigit();
randomString(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH - 2) +
randomUppercaseLetter() +
randomDigit();
export const randomInvalidPassword = () =>
chooseRandomly([

View file

@ -6,11 +6,10 @@ import { Credentials, UserSettings } from 'n8n-core';
import config from '../../../config';
import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants';
import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
import { randomEmail, randomName, randomString, randomValidPassword } from './random';
import { Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
import { hashPassword } from '../../../src/UserManagement/UserManagementHelper';
import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
import { entities } from '../../../src/databases/entities';
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
@ -19,8 +18,11 @@ import { categorize, getPostgresSchemaSection } from './utils';
import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper';
import type { Role } from '../../../src/databases/entities/Role';
import type { User } from '../../../src/databases/entities/User';
import { User } from '../../../src/databases/entities/User';
import type { CollectionName, CredentialPayload } from './types';
import { WorkflowEntity } from '../../../src/databases/entities/WorkflowEntity';
import { ExecutionEntity } from '../../../src/databases/entities/ExecutionEntity';
import { TagEntity } from '../../../src/databases/entities/TagEntity';
const exec = promisify(callbackExec);
@ -56,7 +58,7 @@ export async function init() {
`host: ${pgOptions.host} | port: ${pgOptions.port} | schema: ${pgOptions.schema} | username: ${pgOptions.username} | password: ${pgOptions.password}`,
'Fix by setting correct values via environment variables:',
`${pgConfig.host.env} | ${pgConfig.port.env} | ${pgConfig.schema.env} | ${pgConfig.user.env} | ${pgConfig.password.env}`,
'Otherwise, make sure your Postgres server is running.'
'Otherwise, make sure your Postgres server is running.',
].join('\n');
console.error(message);
@ -72,7 +74,9 @@ export async function init() {
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`);
} catch (error) {
if (error instanceof Error && error.message.includes('command not found')) {
console.error('psql command not found. Make sure psql is installed and added to your PATH.');
console.error(
'psql command not found. Make sure psql is installed and added to your PATH.',
);
}
process.exit(1);
}
@ -228,15 +232,17 @@ export async function saveCredential(
// user creation
// ----------------------------------
export async function createUser(attributes: Partial<User> & { globalRole: Role }): Promise<User> {
/**
* Store a user in the DB, defaulting to a `member`.
*/
export async function createUser(attributes: Partial<User> = {}): Promise<User> {
const { email, password, firstName, lastName, globalRole, ...rest } = attributes;
const user = {
email: email ?? randomEmail(),
password: await hashPassword(password ?? randomValidPassword()),
firstName: firstName ?? randomName(),
lastName: lastName ?? randomName(),
globalRole,
globalRole: globalRole ?? (await getGlobalMemberRole()),
...rest,
};
@ -257,6 +263,11 @@ export function createUserShell(globalRole: Role): Promise<User> {
return Db.collections.User.save(shell);
}
export function addApiKey(user: User): Promise<User> {
user.apiKey = randomApiKey();
return Db.collections.User.save(user);
}
// ----------------------------------
// role fetchers
// ----------------------------------
@ -298,6 +309,187 @@ export function getAllRoles() {
]);
}
// ----------------------------------
// Execution helpers
// ----------------------------------
export async function createManyExecutions(
amount: number,
workflow: WorkflowEntity,
callback: (workflow: WorkflowEntity) => Promise<ExecutionEntity>,
) {
const executionsRequests = [...Array(amount)].map((_) => callback(workflow));
return Promise.all(executionsRequests);
}
/**
* Store a execution in the DB and assigns it to a workflow.
* @param user user to assign the workflow to
*/
export async function createExecution(
attributes: Partial<ExecutionEntity> = {},
workflow: WorkflowEntity,
) {
const { data, finished, mode, startedAt, stoppedAt, waitTill } = attributes;
const execution = await Db.collections.Execution.save({
data: data ?? '[]',
finished: finished ?? true,
mode: mode ?? 'manual',
startedAt: startedAt ?? new Date(),
...(workflow !== undefined && { workflowData: workflow, workflowId: workflow.id.toString() }),
stoppedAt: stoppedAt ?? new Date(),
waitTill: waitTill ?? null,
});
return execution;
}
/**
* Store a execution in the DB and assigns it to a workflow.
* @param user user to assign the workflow to
*/
export async function createSuccessfullExecution(workflow: WorkflowEntity) {
const execution = await createExecution(
{
finished: true,
},
workflow,
);
return execution;
}
/**
* Store a execution in the DB and assigns it to a workflow.
* @param user user to assign the workflow to
*/
export async function createErrorExecution(workflow: WorkflowEntity) {
const execution = await createExecution(
{
finished: false,
stoppedAt: new Date(),
},
workflow,
);
return execution;
}
/**
* Store a execution in the DB and assigns it to a workflow.
* @param user user to assign the workflow to
*/
export async function createWaitingExecution(workflow: WorkflowEntity) {
const execution = await createExecution(
{
finished: false,
waitTill: new Date(),
},
workflow,
);
return execution;
}
// ----------------------------------
// Tags
// ----------------------------------
export async function createTag(attributes: Partial<TagEntity> = {}) {
const { name } = attributes;
return await Db.collections.Tag.save({
name: name ?? randomName(),
...attributes,
});
}
// ----------------------------------
// Workflow helpers
// ----------------------------------
export async function createManyWorkflows(
amount: number,
attributes: Partial<WorkflowEntity> = {},
user?: User,
) {
const workflowRequests = [...Array(amount)].map((_) => createWorkflow(attributes, user));
return Promise.all(workflowRequests);
}
/**
* Store a workflow in the DB (without a trigger) and optionally assigns it to a user.
* @param user user to assign the workflow to
*/
export async function createWorkflow(attributes: Partial<WorkflowEntity> = {}, user?: User) {
const { active, name, nodes, connections } = attributes;
const workflow = await Db.collections.Workflow.save({
active: active ?? false,
name: name ?? 'test workflow',
nodes: nodes ?? [
{
name: 'Start',
parameters: {},
position: [-20, 260],
type: 'n8n-nodes-base.start',
typeVersion: 1,
},
],
connections: connections ?? {},
...attributes,
});
if (user) {
await Db.collections.SharedWorkflow.save({
user,
workflow,
role: await getWorkflowOwnerRole(),
});
}
return workflow;
}
/**
* Store a workflow in the DB (with a trigger) and optionally assigns it to a user.
* @param user user to assign the workflow to
*/
export async function createWorkflowWithTrigger(
attributes: Partial<WorkflowEntity> = {},
user?: User,
) {
const workflow = await createWorkflow(
{
nodes: [
{
parameters: {},
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [240, 300],
},
{
parameters: { triggerTimes: { item: [{ mode: 'everyMinute' }] } },
name: 'Cron',
type: 'n8n-nodes-base.cron',
typeVersion: 1,
position: [500, 300],
},
{
parameters: { options: {} },
name: 'Set',
type: 'n8n-nodes-base.set',
typeVersion: 1,
position: [780, 300],
},
],
connections: { Cron: { main: [[{ node: 'Set', type: 'main', index: 0 }]] } },
...attributes,
},
user,
);
return workflow;
}
// ----------------------------------
// connection options
// ----------------------------------

View file

@ -15,12 +15,14 @@ export type SmtpTestAccount = {
};
};
type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials';
export type ApiPath = 'internal' | 'public';
type EndpointGroup = 'me' | 'users' | 'auth' | 'owner' | 'passwordReset' | 'credentials' | 'publicApi';
export type CredentialPayload = {
name: string;
type: string;
nodesAccess: ICredentialNodeAccess[];
nodesAccess?: ICredentialNodeAccess[];
data: ICredentialDataDecryptedObject;
};

View file

@ -7,14 +7,33 @@ import { URL } from 'url';
import bodyParser from 'body-parser';
import util from 'util';
import { createTestAccount } from 'nodemailer';
import { INodeTypes, LoggerProxy } from 'n8n-workflow';
import { UserSettings } from 'n8n-core';
import {
ICredentialType,
IDataObject,
IExecuteFunctions,
INodeExecutionData,
INodeParameters,
INodeTypeData,
INodeTypes,
ITriggerFunctions,
ITriggerResponse,
LoggerProxy,
} from 'n8n-workflow';
import { BinaryDataManager, UserSettings } from 'n8n-core';
import { CronJob } from 'cron';
import config from '../../../config';
import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants';
import config = require('../../../config');
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants';
import { AUTH_COOKIE_NAME } from '../../../src/constants';
import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes';
import { Db, ExternalHooks, InternalHooksManager } from '../../../src';
import {
ActiveWorkflowRunner,
CredentialTypes,
Db,
ExternalHooks,
InternalHooksManager,
NodeTypes,
} from '../../../src';
import { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me';
import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users';
import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth';
@ -23,9 +42,20 @@ import { passwordResetNamespace as passwordResetEndpoints } from '../../../src/U
import { issueJWT } from '../../../src/UserManagement/auth/jwt';
import { getLogger } from '../../../src/Logger';
import { credentialsController } from '../../../src/api/credentials.api';
import { loadPublicApiVersions } from '../../../src/PublicApi/';
import type { User } from '../../../src/databases/entities/User';
import type { EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types';
import type { ApiPath, EndpointGroup, PostgresSchemaSection, SmtpTestAccount } from './types';
import { Telemetry } from '../../../src/telemetry';
import type { N8nApp } from '../../../src/UserManagement/Interfaces';
import { set } from 'lodash';
interface TriggerTime {
mode: string;
hour: number;
minute: number;
dayOfMonth: number;
weekeday: number;
[key: string]: string | number;
}
import * as UserManagementMailer from '../../../src/UserManagement/email/UserManagementMailer';
/**
@ -34,7 +64,7 @@ import * as UserManagementMailer from '../../../src/UserManagement/email/UserMan
* @param applyAuth Whether to apply auth middleware to test server.
* @param endpointGroups Groups of endpoints to apply to test server.
*/
export function initTestServer({
export async function initTestServer({
applyAuth,
endpointGroups,
}: {
@ -44,6 +74,7 @@ export function initTestServer({
const testServer = {
app: express(),
restEndpoint: REST_PATH_SEGMENT,
publicApiEndpoint: PUBLIC_API_REST_PATH_SEGMENT,
...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}),
};
@ -62,12 +93,18 @@ export function initTestServer({
const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups);
if (routerEndpoints.length) {
const map: Record<string, express.Router> = {
const { apiRouters } = await loadPublicApiVersions(testServer.publicApiEndpoint);
const map: Record<string, express.Router | express.Router[]> = {
credentials: credentialsController,
publicApi: apiRouters
};
for (const group of routerEndpoints) {
testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
if (group === 'publicApi') {
testServer.app.use(...(map[group] as express.Router[]));
} else {
testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
}
}
}
@ -106,7 +143,9 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
const functionEndpoints: string[] = [];
endpointGroups.forEach((group) =>
(group === 'credentials' ? routerEndpoints : functionEndpoints).push(group),
(group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(
group,
),
);
return [routerEndpoints, functionEndpoints];
@ -116,6 +155,598 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
// initializers
// ----------------------------------
/**
* Initialize node types.
*/
export async function initActiveWorkflowRunner(): Promise<ActiveWorkflowRunner.ActiveWorkflowRunner> {
const workflowRunner = ActiveWorkflowRunner.getInstance();
workflowRunner.init();
return workflowRunner;
}
export function gitHubCredentialType(): ICredentialType {
return {
name: 'githubApi',
displayName: 'Github API',
documentationUrl: 'github',
properties: [
{
displayName: 'Github Server',
name: 'server',
type: 'string',
default: 'https://api.github.com',
description: 'The server to connect to. Only has to be set if Github Enterprise is used.',
},
{
displayName: 'User',
name: 'user',
type: 'string',
default: '',
},
{
displayName: 'Access Token',
name: 'accessToken',
type: 'string',
default: '',
},
],
};
}
/**
* Initialize node types.
*/
export async function initCredentialsTypes(): Promise<void> {
const credentialTypes = CredentialTypes();
await credentialTypes.init({
githubApi: {
type: gitHubCredentialType(),
sourcePath: '',
},
});
}
/**
* Initialize node types.
*/
export async function initNodeTypes() {
const types: INodeTypeData = {
'n8n-nodes-base.start': {
sourcePath: '',
type: {
description: {
displayName: 'Start',
name: 'start',
group: ['input'],
version: 1,
description: 'Starts the workflow execution from this node',
defaults: {
name: 'Start',
color: '#553399',
},
inputs: [],
outputs: ['main'],
properties: [],
},
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
return this.prepareOutputData(items);
},
},
},
'n8n-nodes-base.cron': {
sourcePath: '',
type: {
description: {
displayName: 'Cron',
name: 'cron',
icon: 'fa:calendar',
group: ['trigger', 'schedule'],
version: 1,
description: 'Triggers the workflow at a specific time',
eventTriggerDescription: '',
activationMessage:
'Your cron trigger will now trigger executions on the schedule you have defined.',
defaults: {
name: 'Cron',
color: '#00FF00',
},
inputs: [],
outputs: ['main'],
properties: [
{
displayName: 'Trigger Times',
name: 'triggerTimes',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
multipleValueButtonText: 'Add Time',
},
default: {},
description: 'Triggers for the workflow',
placeholder: 'Add Cron Time',
options: [
{
name: 'item',
displayName: 'Item',
values: [
{
displayName: 'Mode',
name: 'mode',
type: 'options',
options: [
{
name: 'Every Minute',
value: 'everyMinute',
},
{
name: 'Every Hour',
value: 'everyHour',
},
{
name: 'Every Day',
value: 'everyDay',
},
{
name: 'Every Week',
value: 'everyWeek',
},
{
name: 'Every Month',
value: 'everyMonth',
},
{
name: 'Every X',
value: 'everyX',
},
{
name: 'Custom',
value: 'custom',
},
],
default: 'everyDay',
description: 'How often to trigger.',
},
{
displayName: 'Hour',
name: 'hour',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 23,
},
displayOptions: {
hide: {
mode: ['custom', 'everyHour', 'everyMinute', 'everyX'],
},
},
default: 14,
description: 'The hour of the day to trigger (24h format).',
},
{
displayName: 'Minute',
name: 'minute',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 59,
},
displayOptions: {
hide: {
mode: ['custom', 'everyMinute', 'everyX'],
},
},
default: 0,
description: 'The minute of the day to trigger.',
},
{
displayName: 'Day of Month',
name: 'dayOfMonth',
type: 'number',
displayOptions: {
show: {
mode: ['everyMonth'],
},
},
typeOptions: {
minValue: 1,
maxValue: 31,
},
default: 1,
description: 'The day of the month to trigger.',
},
{
displayName: 'Weekday',
name: 'weekday',
type: 'options',
displayOptions: {
show: {
mode: ['everyWeek'],
},
},
options: [
{
name: 'Monday',
value: '1',
},
{
name: 'Tuesday',
value: '2',
},
{
name: 'Wednesday',
value: '3',
},
{
name: 'Thursday',
value: '4',
},
{
name: 'Friday',
value: '5',
},
{
name: 'Saturday',
value: '6',
},
{
name: 'Sunday',
value: '0',
},
],
default: '1',
description: 'The weekday to trigger.',
},
{
displayName: 'Cron Expression',
name: 'cronExpression',
type: 'string',
displayOptions: {
show: {
mode: ['custom'],
},
},
default: '* * * * * *',
description:
'Use custom cron expression. Values and ranges as follows:<ul><li>Seconds: 0-59</li><li>Minutes: 0 - 59</li><li>Hours: 0 - 23</li><li>Day of Month: 1 - 31</li><li>Months: 0 - 11 (Jan - Dec)</li><li>Day of Week: 0 - 6 (Sun - Sat)</li></ul>.',
},
{
displayName: 'Value',
name: 'value',
type: 'number',
typeOptions: {
minValue: 0,
maxValue: 1000,
},
displayOptions: {
show: {
mode: ['everyX'],
},
},
default: 2,
description: 'All how many X minutes/hours it should trigger.',
},
{
displayName: 'Unit',
name: 'unit',
type: 'options',
displayOptions: {
show: {
mode: ['everyX'],
},
},
options: [
{
name: 'Minutes',
value: 'minutes',
},
{
name: 'Hours',
value: 'hours',
},
],
default: 'hours',
description: 'If it should trigger all X minutes or hours.',
},
],
},
],
},
],
},
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const triggerTimes = this.getNodeParameter('triggerTimes') as unknown as {
item: TriggerTime[];
};
// Define the order the cron-time-parameter appear
const parameterOrder = [
'second', // 0 - 59
'minute', // 0 - 59
'hour', // 0 - 23
'dayOfMonth', // 1 - 31
'month', // 0 - 11(Jan - Dec)
'weekday', // 0 - 6(Sun - Sat)
];
// Get all the trigger times
const cronTimes: string[] = [];
let cronTime: string[];
let parameterName: string;
if (triggerTimes.item !== undefined) {
for (const item of triggerTimes.item) {
cronTime = [];
if (item.mode === 'custom') {
cronTimes.push(item.cronExpression as string);
continue;
}
if (item.mode === 'everyMinute') {
cronTimes.push(`${Math.floor(Math.random() * 60).toString()} * * * * *`);
continue;
}
if (item.mode === 'everyX') {
if (item.unit === 'minutes') {
cronTimes.push(
`${Math.floor(Math.random() * 60).toString()} */${item.value} * * * *`,
);
} else if (item.unit === 'hours') {
cronTimes.push(
`${Math.floor(Math.random() * 60).toString()} 0 */${item.value} * * *`,
);
}
continue;
}
for (parameterName of parameterOrder) {
if (item[parameterName] !== undefined) {
// Value is set so use it
cronTime.push(item[parameterName] as string);
} else if (parameterName === 'second') {
// For seconds we use by default a random one to make sure to
// balance the load a little bit over time
cronTime.push(Math.floor(Math.random() * 60).toString());
} else {
// For all others set "any"
cronTime.push('*');
}
}
cronTimes.push(cronTime.join(' '));
}
}
// The trigger function to execute when the cron-time got reached
// or when manually triggered
const executeTrigger = () => {
this.emit([this.helpers.returnJsonArray([{}])]);
};
const timezone = this.getTimezone();
// Start the cron-jobs
const cronJobs: CronJob[] = [];
for (const cronTime of cronTimes) {
cronJobs.push(new CronJob(cronTime, executeTrigger, undefined, true, timezone));
}
// Stop the cron-jobs
async function closeFunction() {
for (const cronJob of cronJobs) {
cronJob.stop();
}
}
async function manualTriggerFunction() {
executeTrigger();
}
return {
closeFunction,
manualTriggerFunction,
};
},
},
},
'n8n-nodes-base.set': {
sourcePath: '',
type: {
description: {
displayName: 'Set',
name: 'set',
icon: 'fa:pen',
group: ['input'],
version: 1,
description: 'Sets values on items and optionally remove other values',
defaults: {
name: 'Set',
color: '#0000FF',
},
inputs: ['main'],
outputs: ['main'],
properties: [
{
displayName: 'Keep Only Set',
name: 'keepOnlySet',
type: 'boolean',
default: false,
description:
'If only the values set on this node should be kept and all others removed.',
},
{
displayName: 'Values to Set',
name: 'values',
placeholder: 'Add Value',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
sortable: true,
},
description: 'The value to set.',
default: {},
options: [
{
name: 'boolean',
displayName: 'Boolean',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'boolean',
default: false,
description: 'The boolean value to write in the property.',
},
],
},
{
name: 'number',
displayName: 'Number',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'number',
default: 0,
description: 'The number value to write in the property.',
},
],
},
{
name: 'string',
displayName: 'String',
values: [
{
displayName: 'Name',
name: 'name',
type: 'string',
default: 'propertyName',
description:
'Name of the property to write data to. Supports dot-notation. Example: "data.person[0].name"',
},
{
displayName: 'Value',
name: 'value',
type: 'string',
default: '',
description: 'The string value to write in the property.',
},
],
},
],
},
{
displayName: 'Options',
name: 'options',
type: 'collection',
placeholder: 'Add Option',
default: {},
options: [
{
displayName: 'Dot Notation',
name: 'dotNotation',
type: 'boolean',
default: true,
description: `<p>By default, dot-notation is used in property names. This means that "a.b" will set the property "b" underneath "a" so { "a": { "b": value} }.<p></p>If that is not intended this can be deactivated, it will then set { "a.b": value } instead.</p>
`,
},
],
},
],
},
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
const items = this.getInputData();
if (items.length === 0) {
items.push({ json: {} });
}
const returnData: INodeExecutionData[] = [];
let item: INodeExecutionData;
let keepOnlySet: boolean;
for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
keepOnlySet = this.getNodeParameter('keepOnlySet', itemIndex, false) as boolean;
item = items[itemIndex];
const options = this.getNodeParameter('options', itemIndex, {}) as IDataObject;
const newItem: INodeExecutionData = {
json: {},
};
if (keepOnlySet !== true) {
if (item.binary !== undefined) {
// Create a shallow copy of the binary data so that the old
// data references which do not get changed still stay behind
// but the incoming data does not get changed.
newItem.binary = {};
Object.assign(newItem.binary, item.binary);
}
newItem.json = JSON.parse(JSON.stringify(item.json));
}
// Add boolean values
(this.getNodeParameter('values.boolean', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = !!setItem.value;
} else {
set(newItem.json, setItem.name as string, !!setItem.value);
}
},
);
// Add number values
(this.getNodeParameter('values.number', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
// Add string values
(this.getNodeParameter('values.string', itemIndex, []) as INodeParameters[]).forEach(
(setItem) => {
if (options.dotNotation === false) {
newItem.json[setItem.name as string] = setItem.value;
} else {
set(newItem.json, setItem.name as string, setItem.value);
}
},
);
returnData.push(newItem);
}
return this.prepareOutputData(returnData);
},
},
},
};
const nodeTypes = NodeTypes();
await nodeTypes.init(types);
}
/**
* Initialize a logger for test runs.
*/
@ -123,6 +754,14 @@ export function initTestLogger() {
LoggerProxy.init(getLogger());
}
/**
* Initialize a BinaryManager for test runs.
*/
export async function initBinaryManager() {
const binaryDataConfig = config.getEnv('binaryDataManager');
await BinaryDataManager.init(binaryDataConfig, true);
}
/**
* Initialize a user settings config file if non-existent.
*/
@ -142,13 +781,26 @@ export function initConfigFile() {
/**
* Create a request agent, optionally with an auth cookie.
*/
export function createAgent(app: express.Application, options?: { auth: true; user: User }) {
export function createAgent(
app: express.Application,
options?: { apiPath?: ApiPath; version?: string | number; auth: boolean; user: User },
) {
const agent = request.agent(app);
agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) {
const { token } = issueJWT(options.user);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
agent.use(prefix(REST_PATH_SEGMENT));
if (options?.auth && options?.user) {
const { token } = issueJWT(options.user);
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
}
}
if (options?.apiPath === 'public') {
agent.use(prefix(`${PUBLIC_API_REST_PATH_SEGMENT}/v${options?.version}`));
if (options?.auth && options?.user.apiKey) {
agent.set({ 'X-N8N-API-KEY': options.user.apiKey });
}
}
return agent;
@ -170,7 +822,6 @@ export function prefix(pathSegment: string) {
url.pathname = pathSegment + url.pathname;
request.url = url.toString();
return request;
};
}

View file

@ -30,7 +30,7 @@ let credentialOwnerRole: Role;
let isSmtpAvailable = false;
beforeAll(async () => {
app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
const initResult = await testDb.init();
testDbName = initResult.testDbName;
@ -92,6 +92,7 @@ test('GET /users should return all users', async () => {
password,
resetPasswordToken,
isPending,
apiKey,
} = user;
expect(validator.isUUID(id)).toBe(true);
@ -103,6 +104,7 @@ test('GET /users should return all users', async () => {
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined();
}),
);
});
@ -357,6 +359,7 @@ test('POST /users/:id should fill out a user shell', async () => {
resetPasswordToken,
globalRole,
isPending,
apiKey,
} = response.body.data;
expect(validator.isUUID(id)).toBe(true);
@ -368,6 +371,7 @@ test('POST /users/:id should fill out a user shell', async () => {
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeDefined();
expect(apiKey).not.toBeDefined();
const authToken = utils.getAuthToken(response);
expect(authToken).toBeDefined();
@ -427,6 +431,7 @@ test('POST /users/:id should fail with invalid inputs', async () => {
const storedUser = await Db.collections.User.findOneOrFail({
where: { email: memberShellEmail },
});
expect(storedUser.firstName).toBeNull();
expect(storedUser.lastName).toBeNull();
expect(storedUser.password).toBeNull();

View file

@ -1,6 +1,6 @@
<template functional>
<div :class="$style.container">
<div :class="$style.heading">
<div :class="$style.heading" v-if="props.heading">
<component :is="$options.components.N8nHeading" size="xlarge" align="center">{{ props.heading }}</component>
</div>
<div :class="$style.description">

View file

@ -25,7 +25,11 @@
:size="props.size"
/>
</span>
<span v-if="props.label">{{ props.label }}</span>
<span v-if="props.label || $slots.default">
<slot>
{{ props.label }}
</slot>
</span>
</component>
</template>

View file

@ -0,0 +1,29 @@
/* tslint:disable:variable-name */
import N8nCard from './Card.vue';
import {StoryFn} from "@storybook/vue";
export default {
title: 'Atoms/Card',
component: N8nCard,
};
export const Default: StoryFn = (args, {argTypes}) => ({
props: Object.keys(argTypes),
components: {
N8nCard,
},
template: `<n8n-card v-bind="$props">This is a card.</n8n-card>`,
});
export const WithHeaderAndFooter: StoryFn = (args, {argTypes}) => ({
props: Object.keys(argTypes),
components: {
N8nCard,
},
template: `<n8n-card v-bind="$props">
<template #header>Header</template>
This is a card.
<template #footer>Footer</template>
</n8n-card>`,
});

View file

@ -0,0 +1,49 @@
<template>
<div :class="['card', $style.card]" v-on="$listeners">
<div :class="$style.header" v-if="$slots.header">
<slot name="header" />
</div>
<div :class="$style.body" v-if="$slots.default">
<slot />
</div>
<div :class="$style.footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'n8n-card',
});
</script>
<style lang="scss" module>
.card {
border-radius: var(--border-radius-large);
border: var(--border-base);
background-color: var(--color-background-xlight);
padding: var(--spacing-s);
display: flex;
flex-direction: column;
justify-content: space-between;
}
.header,
.footer {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.body {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,26 @@
import {render} from '@testing-library/vue';
import N8nCard from "../Card.vue";
describe('components', () => {
describe('N8nCard', () => {
it('should render correctly', () => {
const wrapper = render(N8nCard, {
slots: {
default: 'This is a card.',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('should render correctly with header and footer', () => {
const wrapper = render(N8nCard, {
slots: {
header: 'Header',
default: 'This is a card.',
footer: 'Footer',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
});
});

View file

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

View file

@ -1,7 +1,7 @@
<template>
<div :class="[$style[theme], $style[type]]">
<div :class="{[$style[theme]]: true, [$style[type]]: true, [$style.bold]: bold}">
<n8n-tooltip :placement="tooltipPlacement" :popper-class="$style.tooltipPopper" :disabled="type !== 'tooltip'">
<span>
<span :class="$style.iconText">
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle': 'exclamation-triangle'" />
<span v-if="type === 'note'"><slot></slot></span>
</span>
@ -33,6 +33,10 @@ export default {
validator: (value: string): boolean =>
['note', 'tooltip'].includes(value),
},
bold: {
type: Boolean,
default: true,
},
tooltipPlacement: {
type: String,
default: 'top',
@ -44,7 +48,6 @@ export default {
<style lang="scss" module>
.base {
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-bold);
line-height: var(--font-size-s);
word-break: normal;
display: flex;
@ -55,6 +58,10 @@ export default {
}
}
.bold {
font-weight: var(--font-weight-bold);
}
.note {
composes: base;
@ -68,6 +75,10 @@ export default {
display: inline-flex;
}
.iconText {
display: inline-flex;
}
.info-light {
color: var(--color-foreground-dark);
}

View file

@ -38,6 +38,7 @@ import N8nActionToggle from './N8nActionToggle';
import N8nAvatar from './N8nAvatar';
import N8nBadge from './N8nBadge';
import N8nButton from './N8nButton';
import N8nCard from './N8nCard';
import N8nFormBox from './N8nFormBox';
import N8nFormInput from './N8nFormInput';
import N8nFormInputs from './N8nFormInputs';
@ -76,6 +77,7 @@ export {
N8nAvatar,
N8nBadge,
N8nButton,
N8nCard,
N8nHeading,
N8nFormBox,
N8nFormInput,

View file

@ -0,0 +1,67 @@
<template>
<div>
<div
v-for="size in ['5xs', '4xs', '3xs', '2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', '5xl']"
class="spacing-group"
:key="size"
>
<div
class="spacing-example"
:class="`${property[0]}${side ? side[0] : ''}-${size}`"
>
<div class="spacing-box" />
<div class="label">
{{property[0]}}{{side ? side[0] : ''}}-{{size}}
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
name: 'SpacingPreview',
props: {
property: {
type: String,
default: 'padding',
},
side: {
type: String,
default: '',
},
},
});
</script>
<style lang="scss">
$box-size: 64px;
.spacing-group {
border: var(--border-base);
margin: var(--spacing-s);
display: inline-flex;
}
.spacing-example {
background: white;
position: relative;
background: hsla(var(--color-primary-h), var(--color-primary-s), var(--color-primary-l), 0.25);
}
.spacing-box {
width: $box-size;
height: $box-size;
display: block;
position: relative;
background: var(--color-primary);
}
.label {
position: absolute;
bottom: -1rem;
right: 0;
font-size: var(--font-size-s);
}
</style>

View file

@ -0,0 +1,47 @@
/* tslint:disable:variable-name */
import {StoryFn} from "@storybook/vue";
import SpacingPreview from "../styleguide/SpacingPreview.vue";
export default {
title: 'Utilities/Spacing',
};
const Template: StoryFn = (args, {argTypes}) => ({
props: Object.keys(argTypes),
components: {
SpacingPreview,
},
template: `<spacing-preview v-bind="$props" />`,
});
export const Padding = Template.bind({});
Padding.args = { property: 'padding' };
export const PaddingTop = Template.bind({});
PaddingTop.args = { property: 'padding', side: 'top' };
export const PaddingRight = Template.bind({});
PaddingRight.args = { property: 'padding', side: 'right' };
export const PaddingBottom = Template.bind({});
PaddingBottom.args = { property: 'padding', side: 'bottom' };
export const PaddingLeft = Template.bind({});
PaddingLeft.args = { property: 'padding', side: 'left' };
export const Margin = Template.bind({});
Margin.args = { property: 'margin' };
export const MarginTop = Template.bind({});
MarginTop.args = { property: 'margin', side: 'top' };
export const MarginRight = Template.bind({});
MarginRight.args = { property: 'margin', side: 'right' };
export const MarginBottom = Template.bind({});
MarginBottom.args = { property: 'margin', side: 'bottom' };
export const MarginLeft = Template.bind({});
MarginLeft.args = { property: 'margin', side: 'left' };

View file

@ -82,3 +82,4 @@
// @use "./avatar.scss";
@use "./drawer.scss";
// @use "./popconfirm.scss";
@use "./utilities.scss";

View file

@ -0,0 +1,19 @@
@use 'sass:string';
$spacing-sizes: '5xs', '4xs', '3xs', '2xs', 'xs', 's', 'm', 'l', 'xl', '2xl', '3xl', '4xl', '5xl';
$spacing-properties: 'margin', 'padding';
$spacing-sides: 'top', 'right', 'bottom', 'left';
@each $size in $spacing-sizes {
@each $property in $spacing-properties {
@each $side in $spacing-sides {
.#{string.slice($property, 0, 1)}#{string.slice($side, 0, 1)}-#{$size} {
#{$property}-#{$side}: var(--spacing-#{$size}) !important;
}
}
.#{string.slice($property, 0, 1)}-#{$size} {
#{$property}: var(--spacing-#{$size}) !important;
}
}
}

View file

@ -558,6 +558,7 @@ export interface IPermissionGroup {
loginStatus?: ILogInStatus[];
role?: IRole[];
um?: boolean;
api?: boolean;
}
export interface IPermissions {
@ -660,6 +661,11 @@ export interface IN8nUISettings {
enabled: boolean;
host: string;
};
publicApi: {
enabled: boolean;
latestVersion: number;
path: string;
};
}
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
@ -875,6 +881,11 @@ export interface ISettingsState {
promptsData: IN8nPrompts;
userManagement: IUserManagementConfig;
templatesEndpointHealthy: boolean;
api: {
enabled: boolean;
latestVersion: number;
path: string;
};
}
export interface ITemplateState {

View file

@ -0,0 +1,14 @@
import {IRestApiContext} from "@/Interface";
import {makeRestApiRequest} from "@/api/helpers";
export function getApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
return makeRestApiRequest(context, 'GET', '/me/api-key');
}
export function createApiKey(context: IRestApiContext): Promise<{ apiKey: string | null }> {
return makeRestApiRequest(context, 'POST', '/me/api-key');
}
export function deleteApiKey(context: IRestApiContext): Promise<{ success: boolean }> {
return makeRestApiRequest(context, 'DELETE', '/me/api-key');
}

View file

@ -16,7 +16,7 @@
<script lang="ts">
import { genericHelpers } from '@/components/mixins/genericHelpers';
import Card from '@/components/Card.vue';
import Card from '@/components/WorkflowCard.vue';
import mixins from 'vue-typed-mixins';
import NodeList from '@/components/NodeList.vue';

View file

@ -19,7 +19,7 @@
</template>
<script lang="ts">
import Card from '@/components/Card.vue';
import Card from '@/components/WorkflowCard.vue';
import CollectionCard from '@/components/CollectionCard.vue';
import VueAgile from 'vue-agile';

View file

@ -2,11 +2,11 @@
<div>
<n8n-input-label :label="label">
<div :class="$style.copyText" @click="copy">
<span>{{ copyContent }}</span>
<span>{{ value }}</span>
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
</div>
</n8n-input-label>
<div :class="$style.subtitle">{{ subtitle }}</div>
<div v-if="hint" :class="$style.hint">{{ hint }}</div>
</div>
</template>
@ -20,26 +20,36 @@ export default mixins(copyPaste, showMessage).extend({
label: {
type: String,
},
subtitle: {
hint: {
type: String,
},
copyContent: {
value: {
type: String,
},
copyButtonText: {
type: String,
default(): string {
return this.$locale.baseText('generic.copy');
},
},
successMessage: {
toastTitle: {
type: String,
default(): string {
return this.$locale.baseText('generic.copiedToClipboard');
},
},
toastMessage: {
type: String,
},
},
methods: {
copy(): void {
this.copyToClipboard(this.$props.copyContent);
this.$emit('copy');
this.copyToClipboard(this.value);
this.$showMessage({
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
message: this.$props.successMessage,
title: this.toastTitle,
message: this.toastMessage,
type: 'success',
});
},
@ -54,6 +64,8 @@ export default mixins(copyPaste, showMessage).extend({
font-family: Monaco, Consolas;
line-height: 1.5;
font-size: var(--font-size-s);
color: var(--color-text-base);
overflow-wrap: break-word;
}
padding: var(--spacing-xs);
@ -86,7 +98,7 @@ export default mixins(copyPaste, showMessage).extend({
}
}
.subtitle {
.hint {
margin-top: var(--spacing-2xs);
font-size: var(--font-size-2xs);
line-height: var(--font-line-height-loose);

View file

@ -48,10 +48,11 @@
<CopyInput
v-if="isOAuthType && credentialProperties.length"
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
:copyContent="oAuthCallbackUrl"
:value="oAuthCallbackUrl"
:copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
:subtitle="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
:successMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
:hint="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
:toastTitle="$locale.baseText('credentialEdit.credentialEdit.showMessage.title')"
:toastMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
/>
<CredentialInputs

View file

@ -259,7 +259,11 @@ export default mixins(
'isTemplatesEnabled',
]),
canUserAccessSettings(): boolean {
return this.canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS) || this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS);
return [
VIEWS.PERSONAL_SETTINGS,
VIEWS.USERS_SETTINGS,
VIEWS.API_SETTINGS,
].some((route) => this.canUserAccessRouteByName(route));
},
helpMenuItems (): object[] {
return [
@ -612,11 +616,16 @@ export default mixins(
} else if (key === 'executions') {
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
} else if (key === 'settings') {
if ((this.currentUser as IUser).isDefaultUser) {
this.$router.push('/settings/users');
if (this.canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS) || this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS)) {
if ((this.currentUser as IUser).isDefaultUser) {
this.$router.push('/settings/users');
}
else {
this.$router.push('/settings/personal');
}
}
else {
this.$router.push('/settings/personal');
else if (this.canUserAccessRouteByName(VIEWS.API_SETTINGS)) {
this.$router.push('/settings/api');
}
}
},

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