mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-10 06:34:05 -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: {
|
workflowTagsDisabled: {
|
||||||
format: Boolean,
|
format: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "run-script-os",
|
"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",
|
"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\"",
|
"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",
|
"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: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",
|
"test:mysql": "export N8N_LOG_LEVEL=silent; export DB_TYPE=mysqldb; jest",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"typeorm": "ts-node ../../node_modules/typeorm/cli.js"
|
"typeorm": "ts-node -T ../../node_modules/typeorm/cli.js"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"n8n": "./bin/n8n"
|
"n8n": "./bin/n8n"
|
||||||
|
@ -72,8 +72,7 @@
|
||||||
"@types/express": "^4.17.6",
|
"@types/express": "^4.17.6",
|
||||||
"@types/jest": "^27.4.0",
|
"@types/jest": "^27.4.0",
|
||||||
"@types/localtunnel": "^1.9.0",
|
"@types/localtunnel": "^1.9.0",
|
||||||
"@types/lodash.get": "^4.4.6",
|
"@types/lodash": "^4.14.182",
|
||||||
"@types/lodash.merge": "^4.6.6",
|
|
||||||
"@types/node": "14.17.27",
|
"@types/node": "14.17.27",
|
||||||
"@types/open": "^6.1.0",
|
"@types/open": "^6.1.0",
|
||||||
"@types/parseurl": "^1.3.1",
|
"@types/parseurl": "^1.3.1",
|
||||||
|
@ -95,11 +94,14 @@
|
||||||
"typescript": "~4.6.0"
|
"typescript": "~4.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@apidevtools/swagger-cli": "4.0.0",
|
||||||
"@oclif/command": "^1.5.18",
|
"@oclif/command": "^1.5.18",
|
||||||
"@oclif/errors": "^1.2.2",
|
"@oclif/errors": "^1.2.2",
|
||||||
"@rudderstack/rudder-sdk-node": "1.0.6",
|
"@rudderstack/rudder-sdk-node": "1.0.6",
|
||||||
"@types/json-diff": "^0.5.1",
|
"@types/json-diff": "^0.5.1",
|
||||||
"@types/jsonwebtoken": "^8.5.2",
|
"@types/jsonwebtoken": "^8.5.2",
|
||||||
|
"@types/swagger-ui-express": "^4.1.3",
|
||||||
|
"@types/yamljs": "^0.2.31",
|
||||||
"basic-auth": "^2.0.1",
|
"basic-auth": "^2.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"body-parser": "^1.18.3",
|
"body-parser": "^1.18.3",
|
||||||
|
@ -117,16 +119,17 @@
|
||||||
"csrf": "^3.1.0",
|
"csrf": "^3.1.0",
|
||||||
"dotenv": "^8.0.0",
|
"dotenv": "^8.0.0",
|
||||||
"express": "^4.16.4",
|
"express": "^4.16.4",
|
||||||
|
"express-openapi-validator": "^4.13.6",
|
||||||
"fast-glob": "^3.2.5",
|
"fast-glob": "^3.2.5",
|
||||||
"flatted": "^3.2.4",
|
"flatted": "^3.2.4",
|
||||||
"google-timezones-json": "^1.0.2",
|
"google-timezones-json": "^1.0.2",
|
||||||
"inquirer": "^7.0.1",
|
"inquirer": "^7.0.1",
|
||||||
"json-diff": "^0.5.4",
|
"json-diff": "^0.5.4",
|
||||||
|
"jsonschema": "^1.4.1",
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"jwks-rsa": "~1.12.1",
|
"jwks-rsa": "~1.12.1",
|
||||||
"localtunnel": "^2.0.0",
|
"localtunnel": "^2.0.0",
|
||||||
"lodash.get": "^4.4.2",
|
"lodash": "^4.17.21",
|
||||||
"lodash.merge": "^4.6.2",
|
|
||||||
"mysql2": "~2.3.0",
|
"mysql2": "~2.3.0",
|
||||||
"n8n-core": "~0.120.0",
|
"n8n-core": "~0.120.0",
|
||||||
"n8n-editor-ui": "~0.146.0",
|
"n8n-editor-ui": "~0.146.0",
|
||||||
|
@ -135,6 +138,7 @@
|
||||||
"nodemailer": "^6.7.1",
|
"nodemailer": "^6.7.1",
|
||||||
"oauth-1.0a": "^2.2.6",
|
"oauth-1.0a": "^2.2.6",
|
||||||
"open": "^7.0.0",
|
"open": "^7.0.0",
|
||||||
|
"openapi-types": "^10.0.0",
|
||||||
"p-cancelable": "^2.0.0",
|
"p-cancelable": "^2.0.0",
|
||||||
"passport": "^0.5.0",
|
"passport": "^0.5.0",
|
||||||
"passport-cookie": "^1.0.9",
|
"passport-cookie": "^1.0.9",
|
||||||
|
@ -144,10 +148,12 @@
|
||||||
"request-promise-native": "^1.0.7",
|
"request-promise-native": "^1.0.7",
|
||||||
"sqlite3": "^5.0.2",
|
"sqlite3": "^5.0.2",
|
||||||
"sse-channel": "^3.1.1",
|
"sse-channel": "^3.1.1",
|
||||||
|
"swagger-ui-express": "^4.3.0",
|
||||||
"tslib": "1.14.1",
|
"tslib": "1.14.1",
|
||||||
"typeorm": "0.2.30",
|
"typeorm": "0.2.30",
|
||||||
"uuid": "^8.3.0",
|
"uuid": "^8.3.0",
|
||||||
"validator": "13.7.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 DatabaseType = 'mariadb' | 'postgresdb' | 'mysqldb' | 'sqlite';
|
||||||
export type SaveExecutionDataType = 'all' | 'none';
|
export type SaveExecutionDataType = 'all' | 'none';
|
||||||
|
|
||||||
|
export type ExecutionDataFieldFormat = 'empty' | 'flattened' | 'json';
|
||||||
|
|
||||||
export interface IExecutionBase {
|
export interface IExecutionBase {
|
||||||
id?: number | string;
|
id?: number | string;
|
||||||
mode: WorkflowExecuteMode;
|
mode: WorkflowExecuteMode;
|
||||||
|
@ -229,6 +231,19 @@ export interface IExecutionFlattedResponse extends IExecutionFlatted {
|
||||||
retryOf?: string;
|
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 {
|
export interface IExecutionsListResponse {
|
||||||
count: number;
|
count: number;
|
||||||
// results: IExecutionShortResponse[];
|
// results: IExecutionShortResponse[];
|
||||||
|
@ -363,16 +378,20 @@ export interface IInternalHooksClass {
|
||||||
firstWorkflowCreatedAt?: Date,
|
firstWorkflowCreatedAt?: Date,
|
||||||
): Promise<unknown[]>;
|
): Promise<unknown[]>;
|
||||||
onPersonalizationSurveySubmitted(userId: string, answers: Record<string, string>): Promise<void>;
|
onPersonalizationSurveySubmitted(userId: string, answers: Record<string, string>): Promise<void>;
|
||||||
onWorkflowCreated(userId: string, workflow: IWorkflowBase): Promise<void>;
|
onWorkflowCreated(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise<void>;
|
||||||
onWorkflowDeleted(userId: string, workflowId: string): Promise<void>;
|
onWorkflowDeleted(userId: string, workflowId: string, publicApi: boolean): Promise<void>;
|
||||||
onWorkflowSaved(userId: string, workflow: IWorkflowBase): Promise<void>;
|
onWorkflowSaved(userId: string, workflow: IWorkflowBase, publicApi: boolean): Promise<void>;
|
||||||
onWorkflowPostExecute(
|
onWorkflowPostExecute(
|
||||||
executionId: string,
|
executionId: string,
|
||||||
workflow: IWorkflowBase,
|
workflow: IWorkflowBase,
|
||||||
runData?: IRun,
|
runData?: IRun,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<void>;
|
): 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>;
|
onUserInvite(userInviteData: { user_id: string; target_user_id: string[] }): Promise<void>;
|
||||||
onUserReinvite(userReinviteData: { 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>;
|
onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise<void>;
|
||||||
|
@ -468,6 +487,7 @@ export interface IN8nUISettings {
|
||||||
personalizationSurveyEnabled: boolean;
|
personalizationSurveyEnabled: boolean;
|
||||||
defaultLocale: string;
|
defaultLocale: string;
|
||||||
userManagement: IUserManagementSettings;
|
userManagement: IUserManagementSettings;
|
||||||
|
publicApi: IPublicApiSettings;
|
||||||
workflowTagsDisabled: boolean;
|
workflowTagsDisabled: boolean;
|
||||||
logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
|
logLevel: 'info' | 'debug' | 'warn' | 'error' | 'verbose' | 'silent';
|
||||||
hiringBannerEnabled: boolean;
|
hiringBannerEnabled: boolean;
|
||||||
|
@ -495,6 +515,11 @@ export interface IUserManagementSettings {
|
||||||
showSetupOnFirstLoad?: boolean;
|
showSetupOnFirstLoad?: boolean;
|
||||||
smtpSetup: boolean;
|
smtpSetup: boolean;
|
||||||
}
|
}
|
||||||
|
export interface IPublicApiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
latestVersion: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IPackageVersions {
|
export interface IPackageVersions {
|
||||||
cli: string;
|
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);
|
const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
|
||||||
return this.telemetry.track('User created workflow', {
|
return this.telemetry.track('User created workflow', {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
workflow_id: workflow.id,
|
workflow_id: workflow.id,
|
||||||
node_graph: nodeGraph,
|
node_graph: nodeGraph,
|
||||||
node_graph_string: JSON.stringify(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', {
|
return this.telemetry.track('User deleted workflow', {
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
workflow_id: workflowId,
|
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 { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes);
|
||||||
|
|
||||||
const notesCount = Object.keys(nodeGraph.notes).length;
|
const notesCount = Object.keys(nodeGraph.notes).length;
|
||||||
|
@ -98,6 +104,7 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
notes_count_non_overlapping: notesCount - overlappingCount,
|
notes_count_non_overlapping: notesCount - overlappingCount,
|
||||||
version_cli: this.versionCli,
|
version_cli: this.versionCli,
|
||||||
num_tags: workflow.tags?.length ?? 0,
|
num_tags: workflow.tags?.length ?? 0,
|
||||||
|
public_api: publicApi,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -215,21 +222,73 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
async onUserDeletion(
|
async onUserDeletion(
|
||||||
userId: string,
|
userId: string,
|
||||||
userDeletionData: ITelemetryUserDeletionData,
|
userDeletionData: ITelemetryUserDeletionData,
|
||||||
|
publicApi: boolean,
|
||||||
): Promise<void> {
|
): 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);
|
return this.telemetry.track('User invited new user', userInviteData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async onUserReinvite(userReinviteData: {
|
async onUserReinvite(userReinviteData: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
target_user_id: string;
|
target_user_id: string;
|
||||||
|
public_api: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.telemetry.track('User resent new user invite email', userReinviteData);
|
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> {
|
async onUserUpdate(userUpdateData: { user_id: string; fields_changed: string[] }): Promise<void> {
|
||||||
return this.telemetry.track('User changed personal settings', userUpdateData);
|
return this.telemetry.track('User changed personal settings', userUpdateData);
|
||||||
}
|
}
|
||||||
|
@ -248,13 +307,37 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
async onUserTransactionalEmail(userTransactionalEmailData: {
|
async onUserTransactionalEmail(userTransactionalEmailData: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
||||||
|
public_api: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.telemetry.track(
|
return this.telemetry.track(
|
||||||
'Instance sent transactional email to user',
|
'Instance sent transacptional email to user',
|
||||||
userTransactionalEmailData,
|
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> {
|
async onUserPasswordResetRequestClick(userPasswordResetData: { user_id: string }): Promise<void> {
|
||||||
return this.telemetry.track(
|
return this.telemetry.track(
|
||||||
'User requested password reset while logged out',
|
'User requested password reset while logged out',
|
||||||
|
@ -273,6 +356,7 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||||
async onEmailFailed(failedEmailData: {
|
async onEmailFailed(failedEmailData: {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
message_type: 'Reset password' | 'New user invite' | 'Resend invite';
|
||||||
|
public_api: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
return this.telemetry.track(
|
return this.telemetry.track(
|
||||||
'Instance failed to send transactional email to user',
|
'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
|
// @ts-ignore
|
||||||
response.stack = error.stack;
|
response.stack = error.stack;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(httpStatusCode).json(response);
|
res.status(httpStatusCode).json(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -147,12 +146,13 @@ const isUniqueConstraintError = (error: Error) =>
|
||||||
* @returns
|
* @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) => {
|
return async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const data = await processFunction(req, res);
|
const data = await processFunction(req, res);
|
||||||
|
|
||||||
sendSuccessResponse(res, data);
|
sendSuccessResponse(res, data, raw);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && isUniqueConstraintError(error)) {
|
if (error instanceof Error && isUniqueConstraintError(error)) {
|
||||||
error.message = 'There is already an entry with this name';
|
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 clientOAuth1, { RequestOptions } from 'oauth-1.0a';
|
||||||
import csrf from 'csrf';
|
import csrf from 'csrf';
|
||||||
import requestPromise, { OptionsWithUrl } from 'request-promise-native';
|
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
|
// IMPORTANT! Do not switch to anther bcrypt library unless really necessary and
|
||||||
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
|
// tested with all possible systems like Windows, Alpine on ARM, FreeBSD, ...
|
||||||
import { compare } from 'bcryptjs';
|
import { compare } from 'bcryptjs';
|
||||||
|
@ -102,6 +102,7 @@ import {
|
||||||
ICredentialsDb,
|
ICredentialsDb,
|
||||||
ICredentialsOverwrite,
|
ICredentialsOverwrite,
|
||||||
ICustomRequest,
|
ICustomRequest,
|
||||||
|
IDiagnosticInfo,
|
||||||
IExecutionFlattedDb,
|
IExecutionFlattedDb,
|
||||||
IExecutionFlattedResponse,
|
IExecutionFlattedResponse,
|
||||||
IExecutionPushResponse,
|
IExecutionPushResponse,
|
||||||
|
@ -110,7 +111,6 @@ import {
|
||||||
IExecutionsStopData,
|
IExecutionsStopData,
|
||||||
IExecutionsSummary,
|
IExecutionsSummary,
|
||||||
IExternalHooksClass,
|
IExternalHooksClass,
|
||||||
IDiagnosticInfo,
|
|
||||||
IN8nUISettings,
|
IN8nUISettings,
|
||||||
IPackageVersions,
|
IPackageVersions,
|
||||||
ITagWithCountDb,
|
ITagWithCountDb,
|
||||||
|
@ -146,11 +146,13 @@ import { userManagementRouter } from './UserManagement';
|
||||||
import { resolveJwt } from './UserManagement/auth/jwt';
|
import { resolveJwt } from './UserManagement/auth/jwt';
|
||||||
import { User } from './databases/entities/User';
|
import { User } from './databases/entities/User';
|
||||||
import type {
|
import type {
|
||||||
|
AuthenticatedRequest,
|
||||||
|
CredentialRequest,
|
||||||
ExecutionRequest,
|
ExecutionRequest,
|
||||||
WorkflowRequest,
|
|
||||||
NodeParameterOptionsRequest,
|
NodeParameterOptionsRequest,
|
||||||
OAuthRequest,
|
OAuthRequest,
|
||||||
TagsRequest,
|
TagsRequest,
|
||||||
|
WorkflowRequest,
|
||||||
} from './requests';
|
} from './requests';
|
||||||
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
|
import { DEFAULT_EXECUTIONS_GET_ALL_LIMIT, validateEntity } from './GenericHelpers';
|
||||||
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
|
import { ExecutionEntity } from './databases/entities/ExecutionEntity';
|
||||||
|
@ -162,6 +164,7 @@ import {
|
||||||
isEmailSetUp,
|
isEmailSetUp,
|
||||||
isUserManagementEnabled,
|
isUserManagementEnabled,
|
||||||
} from './UserManagement/UserManagementHelper';
|
} from './UserManagement/UserManagementHelper';
|
||||||
|
import { loadPublicApiVersions } from './PublicApi';
|
||||||
|
|
||||||
require('body-parser-xml')(bodyParser);
|
require('body-parser-xml')(bodyParser);
|
||||||
|
|
||||||
|
@ -210,6 +213,8 @@ class App {
|
||||||
|
|
||||||
restEndpoint: string;
|
restEndpoint: string;
|
||||||
|
|
||||||
|
publicApiEndpoint: string;
|
||||||
|
|
||||||
frontendSettings: IN8nUISettings;
|
frontendSettings: IN8nUISettings;
|
||||||
|
|
||||||
protocol: string;
|
protocol: string;
|
||||||
|
@ -234,14 +239,15 @@ class App {
|
||||||
this.defaultWorkflowName = config.getEnv('workflows.defaultName');
|
this.defaultWorkflowName = config.getEnv('workflows.defaultName');
|
||||||
this.defaultCredentialsName = config.getEnv('credentials.defaultName');
|
this.defaultCredentialsName = config.getEnv('credentials.defaultName');
|
||||||
|
|
||||||
this.saveDataErrorExecution = config.getEnv('executions.saveDataOnError');
|
this.saveDataErrorExecution = config.get('executions.saveDataOnError');
|
||||||
this.saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess');
|
this.saveDataSuccessExecution = config.get('executions.saveDataOnSuccess');
|
||||||
this.saveManualExecutions = config.getEnv('executions.saveDataManualExecutions');
|
this.saveManualExecutions = config.get('executions.saveDataManualExecutions');
|
||||||
this.executionTimeout = config.getEnv('executions.timeout');
|
this.executionTimeout = config.get('executions.timeout');
|
||||||
this.maxExecutionTimeout = config.getEnv('executions.maxTimeout');
|
this.maxExecutionTimeout = config.get('executions.maxTimeout');
|
||||||
this.payloadSizeMax = config.getEnv('endpoints.payloadSizeMax');
|
this.payloadSizeMax = config.get('endpoints.payloadSizeMax');
|
||||||
this.timezone = config.getEnv('generic.timezone');
|
this.timezone = config.get('generic.timezone');
|
||||||
this.restEndpoint = config.getEnv('endpoints.rest');
|
this.restEndpoint = config.get('endpoints.rest');
|
||||||
|
this.publicApiEndpoint = config.get('publicApi.path');
|
||||||
|
|
||||||
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
this.activeWorkflowRunner = ActiveWorkflowRunner.getInstance();
|
||||||
this.testWebhooks = TestWebhooks.getInstance();
|
this.testWebhooks = TestWebhooks.getInstance();
|
||||||
|
@ -310,6 +316,11 @@ class App {
|
||||||
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
|
config.getEnv('userManagement.skipInstanceOwnerSetup') === false,
|
||||||
smtpSetup: isEmailSetUp(),
|
smtpSetup: isEmailSetUp(),
|
||||||
},
|
},
|
||||||
|
publicApi: {
|
||||||
|
enabled: config.getEnv('publicApi.disabled') === false,
|
||||||
|
latestVersion: 1,
|
||||||
|
path: config.getEnv('publicApi.path'),
|
||||||
|
},
|
||||||
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'),
|
workflowTagsDisabled: config.getEnv('workflowTagsDisabled'),
|
||||||
logLevel: config.getEnv('logs.level'),
|
logLevel: config.getEnv('logs.level'),
|
||||||
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
|
hiringBannerEnabled: config.getEnv('hiringBanner.enabled'),
|
||||||
|
@ -373,6 +384,9 @@ class App {
|
||||||
this.endpointWebhookTest,
|
this.endpointWebhookTest,
|
||||||
this.endpointPresetCredentials,
|
this.endpointPresetCredentials,
|
||||||
];
|
];
|
||||||
|
if (!config.getEnv('publicApi.disabled')) {
|
||||||
|
ignoredEndpoints.push(this.publicApiEndpoint);
|
||||||
|
}
|
||||||
// eslint-disable-next-line prefer-spread
|
// eslint-disable-next-line prefer-spread
|
||||||
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
|
ignoredEndpoints.push.apply(ignoredEndpoints, excludeEndpoints.split(':'));
|
||||||
|
|
||||||
|
@ -484,8 +498,9 @@ class App {
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
// eslint-disable-next-line no-inner-declarations
|
||||||
function isTenantAllowed(decodedToken: object): boolean {
|
function isTenantAllowed(decodedToken: object): boolean {
|
||||||
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '')
|
if (jwtNamespace === '' || jwtAllowedTenantKey === '' || jwtAllowedTenant === '') {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(decodedToken)) {
|
for (const [k, v] of Object.entries(decodedToken)) {
|
||||||
if (k === jwtNamespace) {
|
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
|
// Parse cookies for easier access
|
||||||
this.app.use(cookieParser());
|
this.app.use(cookieParser());
|
||||||
|
|
||||||
|
@ -786,7 +810,7 @@ class App {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.externalHooks.run('workflow.afterCreate', [savedWorkflow]);
|
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;
|
const { id, ...rest } = savedWorkflow;
|
||||||
|
|
||||||
|
@ -1076,7 +1100,11 @@ class App {
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
|
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) {
|
if (updatedWorkflow.active) {
|
||||||
// When the workflow is supposed to be active add it again
|
// When the workflow is supposed to be active add it again
|
||||||
|
@ -1144,7 +1172,7 @@ class App {
|
||||||
|
|
||||||
await Db.collections.Workflow.delete(workflowId);
|
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]);
|
await this.externalHooks.run('workflow.afterDelete', [workflowId]);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|
|
@ -123,6 +123,7 @@ export function sanitizeUser(user: User, withoutKeys?: string[]): PublicUser {
|
||||||
resetPasswordTokenExpiration,
|
resetPasswordTokenExpiration,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
apiKey,
|
||||||
...sanitizedUser
|
...sanitizedUser
|
||||||
} = user;
|
} = user;
|
||||||
if (withoutKeys) {
|
if (withoutKeys) {
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { Response } from 'express';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { Db } from '../..';
|
import { Db } from '../..';
|
||||||
import { AUTH_COOKIE_NAME } from '../../constants';
|
import { AUTH_COOKIE_NAME } from '../../constants';
|
||||||
import { JwtToken, JwtPayload } from '../Interfaces';
|
import { JwtPayload, JwtToken } from '../Interfaces';
|
||||||
import { User } from '../../databases/entities/User';
|
import { User } from '../../databases/entities/User';
|
||||||
import * as config from '../../../config';
|
import * as config from '../../../config';
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
/* eslint-disable import/no-cycle */
|
/* eslint-disable import/no-cycle */
|
||||||
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import { LoggerProxy as Logger } from 'n8n-workflow';
|
import { LoggerProxy as Logger } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
import { Db, InternalHooksManager, ResponseHelper } from '../..';
|
import { Db, InternalHooksManager, ResponseHelper } from '../..';
|
||||||
import { issueCookie } from '../auth/jwt';
|
import { issueCookie } from '../auth/jwt';
|
||||||
import { N8nApp, PublicUser } from '../Interfaces';
|
import { N8nApp, PublicUser } from '../Interfaces';
|
||||||
|
@ -149,4 +151,58 @@ export function meNamespace(this: N8nApp): void {
|
||||||
return { success: true };
|
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({
|
void InternalHooksManager.getInstance().onEmailFailed({
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
message_type: 'Reset password',
|
message_type: 'Reset password',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
throw new ResponseHelper.ResponseError(
|
throw new ResponseHelper.ResponseError(
|
||||||
|
@ -103,6 +104,7 @@ export function passwordResetNamespace(this: N8nApp): void {
|
||||||
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
||||||
user_id: id,
|
user_id: id,
|
||||||
message_type: 'Reset password',
|
message_type: 'Reset password',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({
|
void InternalHooksManager.getInstance().onUserPasswordResetRequestClick({
|
||||||
|
|
|
@ -156,6 +156,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
void InternalHooksManager.getInstance().onUserInvite({
|
void InternalHooksManager.getInstance().onUserInvite({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
target_user_id: Object.values(createUsers) as string[],
|
target_user_id: Object.values(createUsers) as string[],
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error('Failed to create user shells', { userShells: createUsers });
|
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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
user_id: id!,
|
user_id: id!,
|
||||||
message_type: 'New user invite',
|
message_type: 'New user invite',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
void InternalHooksManager.getInstance().onEmailFailed({
|
void InternalHooksManager.getInstance().onEmailFailed({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
message_type: 'New user invite',
|
message_type: 'New user invite',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
Logger.error('Failed to send email', {
|
Logger.error('Failed to send email', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
|
@ -378,6 +381,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
*/
|
*/
|
||||||
this.app.delete(
|
this.app.delete(
|
||||||
`/${this.restEndpoint}/users/:id`,
|
`/${this.restEndpoint}/users/:id`,
|
||||||
|
// @ts-ignore
|
||||||
ResponseHelper.send(async (req: UserRequest.Delete) => {
|
ResponseHelper.send(async (req: UserRequest.Delete) => {
|
||||||
const { id: idToDelete } = req.params;
|
const { id: idToDelete } = req.params;
|
||||||
|
|
||||||
|
@ -472,7 +476,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
telemetryData.migration_user_id = transferId;
|
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 };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
@ -538,6 +542,7 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
void InternalHooksManager.getInstance().onEmailFailed({
|
void InternalHooksManager.getInstance().onEmailFailed({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
message_type: 'Resend invite',
|
message_type: 'Resend invite',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
Logger.error('Failed to send email', {
|
Logger.error('Failed to send email', {
|
||||||
email: reinvitee.email,
|
email: reinvitee.email,
|
||||||
|
@ -554,11 +559,13 @@ export function usersNamespace(this: N8nApp): void {
|
||||||
void InternalHooksManager.getInstance().onUserReinvite({
|
void InternalHooksManager.getInstance().onUserReinvite({
|
||||||
user_id: req.user.id,
|
user_id: req.user.id,
|
||||||
target_user_id: reinvitee.id,
|
target_user_id: reinvitee.id,
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
void InternalHooksManager.getInstance().onUserTransactionalEmail({
|
||||||
user_id: reinvitee.id,
|
user_id: reinvitee.id,
|
||||||
message_type: 'Resend invite',
|
message_type: 'Resend invite',
|
||||||
|
public_api: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
|
|
@ -10,8 +10,6 @@ import express from 'express';
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import { getConnectionManager } from 'typeorm';
|
import { getConnectionManager } from 'typeorm';
|
||||||
import bodyParser from 'body-parser';
|
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';
|
import compression from 'compression';
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||||
|
|
|
@ -137,6 +137,10 @@ export class User {
|
||||||
this.updatedAt = new Date();
|
this.updatedAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Column({ type: String, nullable: true })
|
||||||
|
@Index({ unique: true })
|
||||||
|
apiKey?: string | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the user is pending setup completion.
|
* 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 { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
|
||||||
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
||||||
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
||||||
|
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
|
||||||
|
|
||||||
export const mysqlMigrations = [
|
export const mysqlMigrations = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -32,4 +33,5 @@ export const mysqlMigrations = [
|
||||||
CreateUserManagement1646992772331,
|
CreateUserManagement1646992772331,
|
||||||
LowerCaseUserEmail1648740597343,
|
LowerCaseUserEmail1648740597343,
|
||||||
AddUserSettings1652367743993,
|
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 { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
|
||||||
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
||||||
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
||||||
|
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
|
||||||
|
|
||||||
export const postgresMigrations = [
|
export const postgresMigrations = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -28,4 +29,5 @@ export const postgresMigrations = [
|
||||||
CreateUserManagement1646992772331,
|
CreateUserManagement1646992772331,
|
||||||
LowerCaseUserEmail1648740597343,
|
LowerCaseUserEmail1648740597343,
|
||||||
AddUserSettings1652367743993,
|
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 { CreateUserManagement1646992772331 } from './1646992772331-CreateUserManagement';
|
||||||
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
import { LowerCaseUserEmail1648740597343 } from './1648740597343-LowerCaseUserEmail';
|
||||||
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
import { AddUserSettings1652367743993 } from './1652367743993-AddUserSettings';
|
||||||
|
import { AddAPIKeyColumn1652905585850 } from './1652905585850-AddAPIKeyColumn';
|
||||||
|
|
||||||
const sqliteMigrations = [
|
const sqliteMigrations = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -28,6 +29,7 @@ const sqliteMigrations = [
|
||||||
CreateUserManagement1646992772331,
|
CreateUserManagement1646992772331,
|
||||||
LowerCaseUserEmail1648740597343,
|
LowerCaseUserEmail1648740597343,
|
||||||
AddUserSettings1652367743993,
|
AddUserSettings1652367743993,
|
||||||
|
AddAPIKeyColumn1652905585850,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
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';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { User } from './databases/entities/User';
|
import { User } from './databases/entities/User';
|
||||||
|
import { Role } from './databases/entities/Role';
|
||||||
import type { IExecutionDeleteFilter, IWorkflowDb } from '.';
|
import type { IExecutionDeleteFilter, IWorkflowDb } from '.';
|
||||||
import type { PublicUser } from './UserManagement/Interfaces';
|
import type { PublicUser } from './UserManagement/Interfaces';
|
||||||
|
import * as UserManagementMailer from './UserManagement/email/UserManagementMailer';
|
||||||
|
|
||||||
export type AuthlessRequest<
|
export type AuthlessRequest<
|
||||||
RouteParams = {},
|
RouteParams = {},
|
||||||
|
@ -26,7 +28,11 @@ export type AuthenticatedRequest<
|
||||||
ResponseBody = {},
|
ResponseBody = {},
|
||||||
RequestBody = {},
|
RequestBody = {},
|
||||||
RequestQuery = {},
|
RequestQuery = {},
|
||||||
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & { user: User };
|
> = express.Request<RouteParams, ResponseBody, RequestBody, RequestQuery> & {
|
||||||
|
user: User;
|
||||||
|
mailer?: UserManagementMailer.UserManagementMailer;
|
||||||
|
globalMemberRole?: Role;
|
||||||
|
};
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// /workflows
|
// /workflows
|
||||||
|
@ -196,7 +202,19 @@ export declare namespace UserRequest {
|
||||||
{ inviterId?: string; inviteeId?: string }
|
{ 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 }>;
|
export type Reinvite = AuthenticatedRequest<{ id: string }>;
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ let globalOwnerRole: Role;
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['auth'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
testDbName = initResult.testDbName;
|
||||||
|
|
||||||
|
@ -68,6 +68,7 @@ test('POST /login should log user in', async () => {
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -81,6 +82,7 @@ test('POST /login should log user in', async () => {
|
||||||
expect(globalRole).toBeDefined();
|
expect(globalRole).toBeDefined();
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
|
@ -146,6 +148,7 @@ test('GET /login should return logged-in owner shell', async () => {
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
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).toBeDefined();
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
|
@ -181,6 +185,7 @@ test('GET /login should return logged-in member shell', async () => {
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
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).toBeDefined();
|
||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
|
@ -216,6 +222,7 @@ test('GET /login should return logged-in owner', async () => {
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -229,6 +236,7 @@ test('GET /login should return logged-in owner', async () => {
|
||||||
expect(globalRole).toBeDefined();
|
expect(globalRole).toBeDefined();
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
|
@ -251,6 +259,7 @@ test('GET /login should return logged-in member', async () => {
|
||||||
personalizationAnswers,
|
personalizationAnswers,
|
||||||
globalRole,
|
globalRole,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -264,6 +273,7 @@ test('GET /login should return logged-in member', async () => {
|
||||||
expect(globalRole).toBeDefined();
|
expect(globalRole).toBeDefined();
|
||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeUndefined();
|
expect(authToken).toBeUndefined();
|
||||||
|
|
|
@ -17,7 +17,7 @@ let testDbName = '';
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({
|
app = await utils.initTestServer({
|
||||||
applyAuth: true,
|
applyAuth: true,
|
||||||
endpointGroups: ['me', 'auth', 'owner', 'users'],
|
endpointGroups: ['me', 'auth', 'owner', 'users'],
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,7 +14,7 @@ let testDbName = '';
|
||||||
let globalOwnerRole: Role;
|
let globalOwnerRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
testDbName = initResult.testDbName;
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ let globalMemberRole: Role;
|
||||||
let saveCredential: SaveCredentialFunction;
|
let saveCredential: SaveCredentialFunction;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({
|
app = await utils.initTestServer({
|
||||||
endpointGroups: ['credentials'],
|
endpointGroups: ['credentials'],
|
||||||
applyAuth: true,
|
applyAuth: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,13 @@ import * as utils from './shared/utils';
|
||||||
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
|
import { SUCCESS_RESPONSE_BODY } from './shared/constants';
|
||||||
import { Db } from '../../src';
|
import { Db } from '../../src';
|
||||||
import type { Role } from '../../src/databases/entities/Role';
|
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';
|
import * as testDb from './shared/testDb';
|
||||||
|
|
||||||
jest.mock('../../src/telemetry');
|
jest.mock('../../src/telemetry');
|
||||||
|
@ -18,7 +24,7 @@ let globalOwnerRole: Role;
|
||||||
let globalMemberRole: Role;
|
let globalMemberRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['me'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
testDbName = initResult.testDbName;
|
||||||
|
|
||||||
|
@ -55,6 +61,7 @@ describe('Owner shell', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -67,6 +74,7 @@ describe('Owner shell', () => {
|
||||||
expect(isPending).toBe(true);
|
expect(isPending).toBe(true);
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
|
@ -88,6 +96,7 @@ describe('Owner shell', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -100,6 +109,7 @@ describe('Owner shell', () => {
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const storedOwnerShell = await Db.collections.User.findOneOrFail(id);
|
const storedOwnerShell = await Db.collections.User.findOneOrFail(id);
|
||||||
|
|
||||||
|
@ -175,6 +185,50 @@ describe('Owner shell', () => {
|
||||||
expect(storedShellOwner.personalizationAnswers).toEqual(validPayload);
|
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', () => {
|
describe('Member', () => {
|
||||||
|
@ -209,6 +263,7 @@ describe('Member', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -221,6 +276,7 @@ describe('Member', () => {
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
|
@ -242,6 +298,7 @@ describe('Member', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -254,6 +311,7 @@ describe('Member', () => {
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole.name).toBe('member');
|
expect(globalRole.name).toBe('member');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const storedMember = await Db.collections.User.findOneOrFail(id);
|
const storedMember = await Db.collections.User.findOneOrFail(id);
|
||||||
|
|
||||||
|
@ -335,6 +393,53 @@ describe('Member', () => {
|
||||||
expect(storedAnswers).toEqual(validPayload);
|
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', () => {
|
describe('Owner', () => {
|
||||||
|
@ -364,6 +469,7 @@ describe('Owner', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -376,6 +482,7 @@ describe('Owner', () => {
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PATCH /me should succeed with valid inputs', async () => {
|
test('PATCH /me should succeed with valid inputs', async () => {
|
||||||
|
@ -397,6 +504,7 @@ describe('Owner', () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -409,6 +517,7 @@ describe('Owner', () => {
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const storedOwner = await Db.collections.User.findOneOrFail(id);
|
const storedOwner = await Db.collections.User.findOneOrFail(id);
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ let testDbName = '';
|
||||||
let globalOwnerRole: Role;
|
let globalOwnerRole: Role;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['owner'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
testDbName = initResult.testDbName;
|
||||||
|
|
||||||
|
@ -66,6 +66,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -78,6 +79,7 @@ test('POST /owner should create owner and enable isInstanceOwnerSetUp', async ()
|
||||||
expect(resetPasswordToken).toBeUndefined();
|
expect(resetPasswordToken).toBeUndefined();
|
||||||
expect(globalRole.name).toBe('owner');
|
expect(globalRole.name).toBe('owner');
|
||||||
expect(globalRole.scope).toBe('global');
|
expect(globalRole.scope).toBe('global');
|
||||||
|
expect(apiKey).toBeUndefined();
|
||||||
|
|
||||||
const storedOwner = await Db.collections.User.findOneOrFail(id);
|
const storedOwner = await Db.collections.User.findOneOrFail(id);
|
||||||
expect(storedOwner.password).not.toBe(newOwnerData.password);
|
expect(storedOwner.password).not.toBe(newOwnerData.password);
|
||||||
|
|
|
@ -24,7 +24,7 @@ let globalMemberRole: Role;
|
||||||
let isSmtpAvailable = false;
|
let isSmtpAvailable = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['passwordReset'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
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 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[]> = [
|
export const AUTHLESS_ENDPOINTS: Readonly<string[]> = [
|
||||||
'healthz',
|
'healthz',
|
||||||
'metrics',
|
'metrics',
|
||||||
|
|
|
@ -10,6 +10,10 @@ export function randomString(min: number, max: number) {
|
||||||
return randomBytes(randomInteger / 2).toString('hex');
|
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 chooseRandomly = <T>(array: T[]) => array[Math.floor(Math.random() * array.length)];
|
||||||
|
|
||||||
const randomDigit = () => Math.floor(Math.random() * 10);
|
const randomDigit = () => Math.floor(Math.random() * 10);
|
||||||
|
@ -17,7 +21,9 @@ const randomDigit = () => Math.floor(Math.random() * 10);
|
||||||
const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
|
const randomUppercaseLetter = () => chooseRandomly('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''));
|
||||||
|
|
||||||
export const randomValidPassword = () =>
|
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 = () =>
|
export const randomInvalidPassword = () =>
|
||||||
chooseRandomly([
|
chooseRandomly([
|
||||||
|
|
|
@ -6,11 +6,10 @@ import { Credentials, UserSettings } from 'n8n-core';
|
||||||
|
|
||||||
import config from '../../../config';
|
import config from '../../../config';
|
||||||
import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants';
|
import { BOOTSTRAP_MYSQL_CONNECTION_NAME, BOOTSTRAP_POSTGRES_CONNECTION_NAME } from './constants';
|
||||||
import { DatabaseType, Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
|
import { Db, ICredentialsDb, IDatabaseCollections } from '../../../src';
|
||||||
import { randomEmail, randomName, randomString, randomValidPassword } from './random';
|
import { randomApiKey, randomEmail, randomName, randomString, randomValidPassword } from './random';
|
||||||
import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
|
import { CredentialsEntity } from '../../../src/databases/entities/CredentialsEntity';
|
||||||
import { hashPassword } from '../../../src/UserManagement/UserManagementHelper';
|
import { hashPassword } from '../../../src/UserManagement/UserManagementHelper';
|
||||||
import { RESPONSE_ERROR_MESSAGES } from '../../../src/constants';
|
|
||||||
import { entities } from '../../../src/databases/entities';
|
import { entities } from '../../../src/databases/entities';
|
||||||
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
|
import { mysqlMigrations } from '../../../src/databases/mysqldb/migrations';
|
||||||
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
|
import { postgresMigrations } from '../../../src/databases/postgresdb/migrations';
|
||||||
|
@ -19,8 +18,11 @@ import { categorize, getPostgresSchemaSection } from './utils';
|
||||||
import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper';
|
import { createCredentiasFromCredentialsEntity } from '../../../src/CredentialsHelper';
|
||||||
|
|
||||||
import type { Role } from '../../../src/databases/entities/Role';
|
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 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);
|
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}`,
|
`host: ${pgOptions.host} | port: ${pgOptions.port} | schema: ${pgOptions.schema} | username: ${pgOptions.username} | password: ${pgOptions.password}`,
|
||||||
'Fix by setting correct values via environment variables:',
|
'Fix by setting correct values via environment variables:',
|
||||||
`${pgConfig.host.env} | ${pgConfig.port.env} | ${pgConfig.schema.env} | ${pgConfig.user.env} | ${pgConfig.password.env}`,
|
`${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');
|
].join('\n');
|
||||||
|
|
||||||
console.error(message);
|
console.error(message);
|
||||||
|
@ -72,7 +74,9 @@ export async function init() {
|
||||||
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`);
|
await exec(`psql -d ${testDbName} -c "CREATE SCHEMA IF NOT EXISTS ${schema}";`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message.includes('command not found')) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
@ -228,15 +232,17 @@ export async function saveCredential(
|
||||||
// user creation
|
// 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 { email, password, firstName, lastName, globalRole, ...rest } = attributes;
|
||||||
|
|
||||||
const user = {
|
const user = {
|
||||||
email: email ?? randomEmail(),
|
email: email ?? randomEmail(),
|
||||||
password: await hashPassword(password ?? randomValidPassword()),
|
password: await hashPassword(password ?? randomValidPassword()),
|
||||||
firstName: firstName ?? randomName(),
|
firstName: firstName ?? randomName(),
|
||||||
lastName: lastName ?? randomName(),
|
lastName: lastName ?? randomName(),
|
||||||
globalRole,
|
globalRole: globalRole ?? (await getGlobalMemberRole()),
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -257,6 +263,11 @@ export function createUserShell(globalRole: Role): Promise<User> {
|
||||||
return Db.collections.User.save(shell);
|
return Db.collections.User.save(shell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addApiKey(user: User): Promise<User> {
|
||||||
|
user.apiKey = randomApiKey();
|
||||||
|
return Db.collections.User.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
// role fetchers
|
// 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
|
// 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 = {
|
export type CredentialPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
nodesAccess: ICredentialNodeAccess[];
|
nodesAccess?: ICredentialNodeAccess[];
|
||||||
data: ICredentialDataDecryptedObject;
|
data: ICredentialDataDecryptedObject;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,14 +7,33 @@ import { URL } from 'url';
|
||||||
import bodyParser from 'body-parser';
|
import bodyParser from 'body-parser';
|
||||||
import util from 'util';
|
import util from 'util';
|
||||||
import { createTestAccount } from 'nodemailer';
|
import { createTestAccount } from 'nodemailer';
|
||||||
import { INodeTypes, LoggerProxy } from 'n8n-workflow';
|
import {
|
||||||
import { UserSettings } from 'n8n-core';
|
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 config = require('../../../config');
|
||||||
import { AUTHLESS_ENDPOINTS, REST_PATH_SEGMENT } from './constants';
|
import { AUTHLESS_ENDPOINTS, PUBLIC_API_REST_PATH_SEGMENT, REST_PATH_SEGMENT } from './constants';
|
||||||
import { AUTH_COOKIE_NAME } from '../../../src/constants';
|
import { AUTH_COOKIE_NAME } from '../../../src/constants';
|
||||||
import { addRoutes as authMiddleware } from '../../../src/UserManagement/routes';
|
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 { meNamespace as meEndpoints } from '../../../src/UserManagement/routes/me';
|
||||||
import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users';
|
import { usersNamespace as usersEndpoints } from '../../../src/UserManagement/routes/users';
|
||||||
import { authenticationMethods as authEndpoints } from '../../../src/UserManagement/routes/auth';
|
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 { issueJWT } from '../../../src/UserManagement/auth/jwt';
|
||||||
import { getLogger } from '../../../src/Logger';
|
import { getLogger } from '../../../src/Logger';
|
||||||
import { credentialsController } from '../../../src/api/credentials.api';
|
import { credentialsController } from '../../../src/api/credentials.api';
|
||||||
|
import { loadPublicApiVersions } from '../../../src/PublicApi/';
|
||||||
import type { User } from '../../../src/databases/entities/User';
|
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 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';
|
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 applyAuth Whether to apply auth middleware to test server.
|
||||||
* @param endpointGroups Groups of endpoints to apply to test server.
|
* @param endpointGroups Groups of endpoints to apply to test server.
|
||||||
*/
|
*/
|
||||||
export function initTestServer({
|
export async function initTestServer({
|
||||||
applyAuth,
|
applyAuth,
|
||||||
endpointGroups,
|
endpointGroups,
|
||||||
}: {
|
}: {
|
||||||
|
@ -44,6 +74,7 @@ export function initTestServer({
|
||||||
const testServer = {
|
const testServer = {
|
||||||
app: express(),
|
app: express(),
|
||||||
restEndpoint: REST_PATH_SEGMENT,
|
restEndpoint: REST_PATH_SEGMENT,
|
||||||
|
publicApiEndpoint: PUBLIC_API_REST_PATH_SEGMENT,
|
||||||
...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}),
|
...(endpointGroups?.includes('credentials') ? { externalHooks: ExternalHooks() } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -62,14 +93,20 @@ export function initTestServer({
|
||||||
const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups);
|
const [routerEndpoints, functionEndpoints] = classifyEndpointGroups(endpointGroups);
|
||||||
|
|
||||||
if (routerEndpoints.length) {
|
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,
|
credentials: credentialsController,
|
||||||
|
publicApi: apiRouters
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const group of routerEndpoints) {
|
for (const group of routerEndpoints) {
|
||||||
|
if (group === 'publicApi') {
|
||||||
|
testServer.app.use(...(map[group] as express.Router[]));
|
||||||
|
} else {
|
||||||
testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
|
testServer.app.use(`/${testServer.restEndpoint}/${group}`, map[group]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (functionEndpoints.length) {
|
if (functionEndpoints.length) {
|
||||||
const map: Record<string, (this: N8nApp) => void> = {
|
const map: Record<string, (this: N8nApp) => void> = {
|
||||||
|
@ -106,7 +143,9 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
|
||||||
const functionEndpoints: string[] = [];
|
const functionEndpoints: string[] = [];
|
||||||
|
|
||||||
endpointGroups.forEach((group) =>
|
endpointGroups.forEach((group) =>
|
||||||
(group === 'credentials' ? routerEndpoints : functionEndpoints).push(group),
|
(group === 'credentials' || group === 'publicApi' ? routerEndpoints : functionEndpoints).push(
|
||||||
|
group,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
return [routerEndpoints, functionEndpoints];
|
return [routerEndpoints, functionEndpoints];
|
||||||
|
@ -116,6 +155,598 @@ const classifyEndpointGroups = (endpointGroups: string[]) => {
|
||||||
// initializers
|
// 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.
|
* Initialize a logger for test runs.
|
||||||
*/
|
*/
|
||||||
|
@ -123,6 +754,14 @@ export function initTestLogger() {
|
||||||
LoggerProxy.init(getLogger());
|
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.
|
* Initialize a user settings config file if non-existent.
|
||||||
*/
|
*/
|
||||||
|
@ -142,14 +781,27 @@ export function initConfigFile() {
|
||||||
/**
|
/**
|
||||||
* Create a request agent, optionally with an auth cookie.
|
* 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);
|
const agent = request.agent(app);
|
||||||
agent.use(prefix(REST_PATH_SEGMENT));
|
|
||||||
|
|
||||||
|
if (options?.apiPath === undefined || options?.apiPath === 'internal') {
|
||||||
|
agent.use(prefix(REST_PATH_SEGMENT));
|
||||||
if (options?.auth && options?.user) {
|
if (options?.auth && options?.user) {
|
||||||
const { token } = issueJWT(options.user);
|
const { token } = issueJWT(options.user);
|
||||||
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
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;
|
return agent;
|
||||||
}
|
}
|
||||||
|
@ -170,7 +822,6 @@ export function prefix(pathSegment: string) {
|
||||||
|
|
||||||
url.pathname = pathSegment + url.pathname;
|
url.pathname = pathSegment + url.pathname;
|
||||||
request.url = url.toString();
|
request.url = url.toString();
|
||||||
|
|
||||||
return request;
|
return request;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ let credentialOwnerRole: Role;
|
||||||
let isSmtpAvailable = false;
|
let isSmtpAvailable = false;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
|
app = await utils.initTestServer({ endpointGroups: ['users'], applyAuth: true });
|
||||||
const initResult = await testDb.init();
|
const initResult = await testDb.init();
|
||||||
testDbName = initResult.testDbName;
|
testDbName = initResult.testDbName;
|
||||||
|
|
||||||
|
@ -92,6 +92,7 @@ test('GET /users should return all users', async () => {
|
||||||
password,
|
password,
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = user;
|
} = user;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
expect(validator.isUUID(id)).toBe(true);
|
||||||
|
@ -103,6 +104,7 @@ test('GET /users should return all users', async () => {
|
||||||
expect(resetPasswordToken).toBeUndefined();
|
expect(resetPasswordToken).toBeUndefined();
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole).toBeDefined();
|
expect(globalRole).toBeDefined();
|
||||||
|
expect(apiKey).not.toBeDefined();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -357,6 +359,7 @@ test('POST /users/:id should fill out a user shell', async () => {
|
||||||
resetPasswordToken,
|
resetPasswordToken,
|
||||||
globalRole,
|
globalRole,
|
||||||
isPending,
|
isPending,
|
||||||
|
apiKey,
|
||||||
} = response.body.data;
|
} = response.body.data;
|
||||||
|
|
||||||
expect(validator.isUUID(id)).toBe(true);
|
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(resetPasswordToken).toBeUndefined();
|
||||||
expect(isPending).toBe(false);
|
expect(isPending).toBe(false);
|
||||||
expect(globalRole).toBeDefined();
|
expect(globalRole).toBeDefined();
|
||||||
|
expect(apiKey).not.toBeDefined();
|
||||||
|
|
||||||
const authToken = utils.getAuthToken(response);
|
const authToken = utils.getAuthToken(response);
|
||||||
expect(authToken).toBeDefined();
|
expect(authToken).toBeDefined();
|
||||||
|
@ -427,6 +431,7 @@ test('POST /users/:id should fail with invalid inputs', async () => {
|
||||||
const storedUser = await Db.collections.User.findOneOrFail({
|
const storedUser = await Db.collections.User.findOneOrFail({
|
||||||
where: { email: memberShellEmail },
|
where: { email: memberShellEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(storedUser.firstName).toBeNull();
|
expect(storedUser.firstName).toBeNull();
|
||||||
expect(storedUser.lastName).toBeNull();
|
expect(storedUser.lastName).toBeNull();
|
||||||
expect(storedUser.password).toBeNull();
|
expect(storedUser.password).toBeNull();
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template functional>
|
<template functional>
|
||||||
<div :class="$style.container">
|
<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>
|
<component :is="$options.components.N8nHeading" size="xlarge" align="center">{{ props.heading }}</component>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.description">
|
<div :class="$style.description">
|
||||||
|
|
|
@ -25,7 +25,11 @@
|
||||||
:size="props.size"
|
:size="props.size"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="props.label">{{ props.label }}</span>
|
<span v-if="props.label || $slots.default">
|
||||||
|
<slot>
|
||||||
|
{{ props.label }}
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</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>
|
<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'">
|
<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'" />
|
<n8n-icon :icon="theme.startsWith('info') ? 'info-circle': 'exclamation-triangle'" />
|
||||||
<span v-if="type === 'note'"><slot></slot></span>
|
<span v-if="type === 'note'"><slot></slot></span>
|
||||||
</span>
|
</span>
|
||||||
|
@ -33,6 +33,10 @@ export default {
|
||||||
validator: (value: string): boolean =>
|
validator: (value: string): boolean =>
|
||||||
['note', 'tooltip'].includes(value),
|
['note', 'tooltip'].includes(value),
|
||||||
},
|
},
|
||||||
|
bold: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
tooltipPlacement: {
|
tooltipPlacement: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'top',
|
default: 'top',
|
||||||
|
@ -44,7 +48,6 @@ export default {
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.base {
|
.base {
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
font-weight: var(--font-weight-bold);
|
|
||||||
line-height: var(--font-size-s);
|
line-height: var(--font-size-s);
|
||||||
word-break: normal;
|
word-break: normal;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -55,6 +58,10 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bold {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
}
|
||||||
|
|
||||||
.note {
|
.note {
|
||||||
composes: base;
|
composes: base;
|
||||||
|
|
||||||
|
@ -68,6 +75,10 @@ export default {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iconText {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.info-light {
|
.info-light {
|
||||||
color: var(--color-foreground-dark);
|
color: var(--color-foreground-dark);
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,7 @@ import N8nActionToggle from './N8nActionToggle';
|
||||||
import N8nAvatar from './N8nAvatar';
|
import N8nAvatar from './N8nAvatar';
|
||||||
import N8nBadge from './N8nBadge';
|
import N8nBadge from './N8nBadge';
|
||||||
import N8nButton from './N8nButton';
|
import N8nButton from './N8nButton';
|
||||||
|
import N8nCard from './N8nCard';
|
||||||
import N8nFormBox from './N8nFormBox';
|
import N8nFormBox from './N8nFormBox';
|
||||||
import N8nFormInput from './N8nFormInput';
|
import N8nFormInput from './N8nFormInput';
|
||||||
import N8nFormInputs from './N8nFormInputs';
|
import N8nFormInputs from './N8nFormInputs';
|
||||||
|
@ -76,6 +77,7 @@ export {
|
||||||
N8nAvatar,
|
N8nAvatar,
|
||||||
N8nBadge,
|
N8nBadge,
|
||||||
N8nButton,
|
N8nButton,
|
||||||
|
N8nCard,
|
||||||
N8nHeading,
|
N8nHeading,
|
||||||
N8nFormBox,
|
N8nFormBox,
|
||||||
N8nFormInput,
|
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 "./avatar.scss";
|
||||||
@use "./drawer.scss";
|
@use "./drawer.scss";
|
||||||
// @use "./popconfirm.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[];
|
loginStatus?: ILogInStatus[];
|
||||||
role?: IRole[];
|
role?: IRole[];
|
||||||
um?: boolean;
|
um?: boolean;
|
||||||
|
api?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPermissions {
|
export interface IPermissions {
|
||||||
|
@ -660,6 +661,11 @@ export interface IN8nUISettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
};
|
};
|
||||||
|
publicApi: {
|
||||||
|
enabled: boolean;
|
||||||
|
latestVersion: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
|
export interface IWorkflowSettings extends IWorkflowSettingsWorkflow {
|
||||||
|
@ -875,6 +881,11 @@ export interface ISettingsState {
|
||||||
promptsData: IN8nPrompts;
|
promptsData: IN8nPrompts;
|
||||||
userManagement: IUserManagementConfig;
|
userManagement: IUserManagementConfig;
|
||||||
templatesEndpointHealthy: boolean;
|
templatesEndpointHealthy: boolean;
|
||||||
|
api: {
|
||||||
|
enabled: boolean;
|
||||||
|
latestVersion: number;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITemplateState {
|
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">
|
<script lang="ts">
|
||||||
import { genericHelpers } from '@/components/mixins/genericHelpers';
|
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 mixins from 'vue-typed-mixins';
|
||||||
import NodeList from '@/components/NodeList.vue';
|
import NodeList from '@/components/NodeList.vue';
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from '@/components/Card.vue';
|
import Card from '@/components/WorkflowCard.vue';
|
||||||
import CollectionCard from '@/components/CollectionCard.vue';
|
import CollectionCard from '@/components/CollectionCard.vue';
|
||||||
import VueAgile from 'vue-agile';
|
import VueAgile from 'vue-agile';
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,11 @@
|
||||||
<div>
|
<div>
|
||||||
<n8n-input-label :label="label">
|
<n8n-input-label :label="label">
|
||||||
<div :class="$style.copyText" @click="copy">
|
<div :class="$style.copyText" @click="copy">
|
||||||
<span>{{ copyContent }}</span>
|
<span>{{ value }}</span>
|
||||||
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
|
<div :class="$style.copyButton"><span>{{ copyButtonText }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</n8n-input-label>
|
</n8n-input-label>
|
||||||
<div :class="$style.subtitle">{{ subtitle }}</div>
|
<div v-if="hint" :class="$style.hint">{{ hint }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -20,26 +20,36 @@ export default mixins(copyPaste, showMessage).extend({
|
||||||
label: {
|
label: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
subtitle: {
|
hint: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
copyContent: {
|
value: {
|
||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
copyButtonText: {
|
copyButtonText: {
|
||||||
type: String,
|
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,
|
type: String,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
copy(): void {
|
copy(): void {
|
||||||
this.copyToClipboard(this.$props.copyContent);
|
this.$emit('copy');
|
||||||
|
this.copyToClipboard(this.value);
|
||||||
|
|
||||||
this.$showMessage({
|
this.$showMessage({
|
||||||
title: this.$locale.baseText('credentialEdit.credentialEdit.showMessage.title'),
|
title: this.toastTitle,
|
||||||
message: this.$props.successMessage,
|
message: this.toastMessage,
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -54,6 +64,8 @@ export default mixins(copyPaste, showMessage).extend({
|
||||||
font-family: Monaco, Consolas;
|
font-family: Monaco, Consolas;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: var(--font-size-s);
|
font-size: var(--font-size-s);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-xs);
|
||||||
|
@ -86,7 +98,7 @@ export default mixins(copyPaste, showMessage).extend({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.hint {
|
||||||
margin-top: var(--spacing-2xs);
|
margin-top: var(--spacing-2xs);
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
line-height: var(--font-line-height-loose);
|
line-height: var(--font-line-height-loose);
|
||||||
|
|
|
@ -48,10 +48,11 @@
|
||||||
<CopyInput
|
<CopyInput
|
||||||
v-if="isOAuthType && credentialProperties.length"
|
v-if="isOAuthType && credentialProperties.length"
|
||||||
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
|
:label="$locale.baseText('credentialEdit.credentialConfig.oAuthRedirectUrl')"
|
||||||
:copyContent="oAuthCallbackUrl"
|
:value="oAuthCallbackUrl"
|
||||||
:copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
|
:copyButtonText="$locale.baseText('credentialEdit.credentialConfig.clickToCopy')"
|
||||||
:subtitle="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
|
:hint="$locale.baseText('credentialEdit.credentialConfig.subtitle', { interpolate: { appName } })"
|
||||||
:successMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
|
:toastTitle="$locale.baseText('credentialEdit.credentialEdit.showMessage.title')"
|
||||||
|
:toastMessage="$locale.baseText('credentialEdit.credentialConfig.redirectUrlCopiedToClipboard')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CredentialInputs
|
<CredentialInputs
|
||||||
|
|
|
@ -259,7 +259,11 @@ export default mixins(
|
||||||
'isTemplatesEnabled',
|
'isTemplatesEnabled',
|
||||||
]),
|
]),
|
||||||
canUserAccessSettings(): boolean {
|
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[] {
|
helpMenuItems (): object[] {
|
||||||
return [
|
return [
|
||||||
|
@ -612,6 +616,7 @@ export default mixins(
|
||||||
} else if (key === 'executions') {
|
} else if (key === 'executions') {
|
||||||
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
|
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
|
||||||
} else if (key === 'settings') {
|
} else if (key === 'settings') {
|
||||||
|
if (this.canUserAccessRouteByName(VIEWS.PERSONAL_SETTINGS) || this.canUserAccessRouteByName(VIEWS.USERS_SETTINGS)) {
|
||||||
if ((this.currentUser as IUser).isDefaultUser) {
|
if ((this.currentUser as IUser).isDefaultUser) {
|
||||||
this.$router.push('/settings/users');
|
this.$router.push('/settings/users');
|
||||||
}
|
}
|
||||||
|
@ -619,6 +624,10 @@ export default mixins(
|
||||||
this.$router.push('/settings/personal');
|
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