mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
1999f4b066
commit
a18081d749
791
package-lock.json
generated
791
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
126
packages/cli/src/PublicApi/index.ts
Normal file
126
packages/cli/src/PublicApi/index.ts
Normal 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,
|
||||
};
|
||||
};
|
26
packages/cli/src/PublicApi/swaggerTheme.css
Normal file
26
packages/cli/src/PublicApi/swaggerTheme.css
Normal file
File diff suppressed because one or more lines are too long
165
packages/cli/src/PublicApi/types.d.ts
vendored
Normal file
165
packages/cli/src/PublicApi/types.d.ts
vendored
Normal 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[];
|
||||
}
|
|
@ -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));
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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();
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
|
@ -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'
|
|
@ -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.
|
|
@ -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'
|
|
@ -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
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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);
|
||||
}
|
|
@ -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'
|
|
@ -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'
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
name: id
|
||||
in: path
|
||||
description: The ID of the execution.
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
|
@ -0,0 +1,6 @@
|
|||
name: includeData
|
||||
in: query
|
||||
description: Whether or not to include the execution's detailed data.
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
|
@ -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',
|
||||
});
|
||||
}
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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'
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
name: id
|
||||
in: path
|
||||
description: The ID of the workflow.
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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);
|
||||
},
|
||||
],
|
||||
};
|
|
@ -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());
|
||||
}
|
59
packages/cli/src/PublicApi/v1/openapi.yml
Normal file
59
packages/cli/src/PublicApi/v1/openapi.yml
Normal 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: []
|
|
@ -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();
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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'
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,6 @@
|
|||
NotFound:
|
||||
$ref: './notFound.yml'
|
||||
Unauthorized:
|
||||
$ref: './unauthorized.yml'
|
||||
BadRequest:
|
||||
$ref: './badRequest.yml'
|
|
@ -0,0 +1 @@
|
|||
description: The request is invalid or provides malformed data.
|
|
@ -0,0 +1 @@
|
|||
description: The specified resource was not found.
|
|
@ -0,0 +1 @@
|
|||
description: Unauthorized
|
20
packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml
Normal file
20
packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml
Normal 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'
|
10
packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml
Normal file
10
packages/cli/src/PublicApi/v1/shared/spec/schemas/error.yml
Normal file
|
@ -0,0 +1,10 @@
|
|||
required:
|
||||
- message
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -123,6 +123,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
|
|||
resetPasswordTokenExpiration,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
apiKey,
|
||||
...sanitizedUser
|
||||
} = user;
|
||||
if (withoutKeys) {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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 };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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`');
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
22
packages/cli/src/requests.d.ts
vendored
22
packages/cli/src/requests.d.ts
vendored
|
@ -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 }>;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ let globalMemberRole: Role;
|
|||
let saveCredential: SaveCredentialFunction;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = utils.initTestServer({
|
||||
app = await utils.initTestServer({
|
||||
endpointGroups: ['credentials'],
|
||||
applyAuth: true,
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
411
packages/cli/test/integration/publicApi/credentials.test.ts
Normal file
411
packages/cli/test/integration/publicApi/credentials.test.ts
Normal 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 });
|
||||
}
|
423
packages/cli/test/integration/publicApi/executions.test.ts
Normal file
423
packages/cli/test/integration/publicApi/executions.test.ts
Normal 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();
|
||||
}
|
||||
});
|
1268
packages/cli/test/integration/publicApi/workflows.test.ts
Normal file
1268
packages/cli/test/integration/publicApi/workflows.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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',
|
||||
|
|
|
@ -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([
|
||||
|
|
|
@ -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
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>`,
|
||||
});
|
49
packages/design-system/src/components/N8nCard/Card.vue
Normal file
49
packages/design-system/src/components/N8nCard/Card.vue
Normal 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>
|
26
packages/design-system/src/components/N8nCard/Card.xpec.ts
Normal file
26
packages/design-system/src/components/N8nCard/Card.xpec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
3
packages/design-system/src/components/N8nCard/index.ts
Normal file
3
packages/design-system/src/components/N8nCard/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import N8nCard from './Card.vue';
|
||||
|
||||
export default N8nCard;
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
67
packages/design-system/src/styleguide/SpacingPreview.vue
Normal file
67
packages/design-system/src/styleguide/SpacingPreview.vue
Normal 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>
|
47
packages/design-system/src/styleguide/utilities.stories.ts
Normal file
47
packages/design-system/src/styleguide/utilities.stories.ts
Normal 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' };
|
||||
|
|
@ -82,3 +82,4 @@
|
|||
// @use "./avatar.scss";
|
||||
@use "./drawer.scss";
|
||||
// @use "./popconfirm.scss";
|
||||
@use "./utilities.scss";
|
||||
|
|
19
packages/design-system/theme/src/utilities.scss
Normal file
19
packages/design-system/theme/src/utilities.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
14
packages/editor-ui/src/api/api-keys.ts
Normal file
14
packages/editor-ui/src/api/api-keys.ts
Normal 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');
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue