diff --git a/packages/@n8n_io/eslint-config/local-rules.js b/packages/@n8n_io/eslint-config/local-rules.js index c9b533a032e1e..152415e14c8d0 100644 --- a/packages/@n8n_io/eslint-config/local-rules.js +++ b/packages/@n8n_io/eslint-config/local-rules.js @@ -448,6 +448,36 @@ module.exports = { }; }, }, + + 'no-type-unsafe-event-emitter': { + meta: { + type: 'problem', + docs: { + description: 'Disallow extending from `EventEmitter`, which is not type-safe.', + recommended: 'error', + }, + messages: { + noExtendsEventEmitter: 'Extend from the type-safe `TypedEmitter` class instead.', + }, + }, + create(context) { + return { + ClassDeclaration(node) { + if ( + node.superClass && + node.superClass.type === 'Identifier' && + node.superClass.name === 'EventEmitter' && + node.id.name !== 'TypedEmitter' + ) { + context.report({ + node: node.superClass, + messageId: 'noExtendsEventEmitter', + }); + } + }, + }; + }, + }, }; const isJsonParseCall = (node) => diff --git a/packages/cli/.eslintrc.js b/packages/cli/.eslintrc.js index ac66574887551..bb1041607ddcd 100644 --- a/packages/cli/.eslintrc.js +++ b/packages/cli/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { rules: { 'n8n-local-rules/no-dynamic-import-template': 'error', 'n8n-local-rules/misplaced-n8n-typeorm-import': 'error', + 'n8n-local-rules/no-type-unsafe-event-emitter': 'error', complexity: 'error', // TODO: Remove this @@ -44,6 +45,12 @@ module.exports = { 'n8n-local-rules/misplaced-n8n-typeorm-import': 'off', }, }, + { + files: ['./test/**/*.ts'], + rules: { + 'n8n-local-rules/no-type-unsafe-event-emitter': 'off', + }, + }, { files: ['./src/decorators/**/*.ts'], rules: { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index fda2c3f21dfc7..46637106b7a47 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -1,23 +1,10 @@ import { Service } from 'typedi'; import { snakeCase } from 'change-case'; -import { get as pslGet } from 'psl'; -import type { - ExecutionStatus, - INodesGraphResult, - IRun, - ITelemetryTrackProperties, - IWorkflowBase, -} from 'n8n-workflow'; -import { TelemetryHelpers } from 'n8n-workflow'; - -import { N8N_VERSION } from '@/constants'; +import type { ITelemetryTrackProperties } from 'n8n-workflow'; import type { AuthProviderType } from '@db/entities/AuthIdentity'; import type { User } from '@db/entities/User'; -import { SharedWorkflowRepository } from '@db/repositories/sharedWorkflow.repository'; -import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; -import type { ITelemetryUserDeletionData, IExecutionTrackProperties } from '@/Interfaces'; +import type { ITelemetryUserDeletionData } from '@/Interfaces'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; -import { NodeTypes } from '@/NodeTypes'; import { Telemetry } from '@/telemetry'; import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; @@ -30,8 +17,6 @@ import { MessageEventBus } from './eventbus/MessageEventBus/MessageEventBus'; export class InternalHooks { constructor( private readonly telemetry: Telemetry, - private readonly nodeTypes: NodeTypes, - private readonly sharedWorkflowRepository: SharedWorkflowRepository, workflowStatisticsService: WorkflowStatisticsService, // Can't use @ts-expect-error because only dev time tsconfig considers this as an error, but not build time // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -64,145 +49,6 @@ export class InternalHooks { this.telemetry.track('User responded to personalization questions', personalizationSurveyData); } - // eslint-disable-next-line complexity - async onWorkflowPostExecute( - _executionId: string, - workflow: IWorkflowBase, - runData?: IRun, - userId?: string, - ) { - if (!workflow.id) { - return; - } - - if (runData?.status === 'waiting') { - // No need to send telemetry or logs when the workflow hasn't finished yet. - return; - } - - const telemetryProperties: IExecutionTrackProperties = { - workflow_id: workflow.id, - is_manual: false, - version_cli: N8N_VERSION, - success: false, - }; - - if (userId) { - telemetryProperties.user_id = userId; - } - - if (runData?.data.resultData.error?.message?.includes('canceled')) { - runData.status = 'canceled'; - } - - telemetryProperties.success = !!runData?.finished; - - // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; - const executionStatus: ExecutionStatus = runData - ? determineFinalExecutionStatus(runData) - : 'unknown'; - - if (runData !== undefined) { - telemetryProperties.execution_mode = runData.mode; - telemetryProperties.is_manual = runData.mode === 'manual'; - - let nodeGraphResult: INodesGraphResult | null = null; - - if (!telemetryProperties.success && runData?.data.resultData.error) { - telemetryProperties.error_message = runData?.data.resultData.error.message; - let errorNodeName = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.name - : undefined; - telemetryProperties.error_node_type = - 'node' in runData?.data.resultData.error - ? runData?.data.resultData.error.node?.type - : undefined; - - if (runData.data.resultData.lastNodeExecuted) { - const lastNode = TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.resultData.lastNodeExecuted, - ); - - if (lastNode !== undefined) { - telemetryProperties.error_node_type = lastNode.type; - errorNodeName = lastNode.name; - } - } - - if (telemetryProperties.is_manual) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - telemetryProperties.node_graph = nodeGraphResult.nodeGraph; - telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - - if (errorNodeName) { - telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; - } - } - } - - if (telemetryProperties.is_manual) { - if (!nodeGraphResult) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - } - - let userRole: 'owner' | 'sharee' | undefined = undefined; - if (userId) { - const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); - if (role) { - userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; - } - } - - const manualExecEventProperties: ITelemetryTrackProperties = { - user_id: userId, - workflow_id: workflow.id, - status: executionStatus, - executionStatus: runData?.status ?? 'unknown', - error_message: telemetryProperties.error_message as string, - error_node_type: telemetryProperties.error_node_type, - node_graph_string: telemetryProperties.node_graph_string as string, - error_node_id: telemetryProperties.error_node_id as string, - webhook_domain: null, - sharing_role: userRole, - }; - - if (!manualExecEventProperties.node_graph_string) { - nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); - manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); - } - - if (runData.data.startData?.destinationNode) { - const telemetryPayload = { - ...manualExecEventProperties, - node_type: TelemetryHelpers.getNodeTypeForName( - workflow, - runData.data.startData?.destinationNode, - )?.type, - node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], - }; - - this.telemetry.track('Manual node exec finished', telemetryPayload); - } else { - nodeGraphResult.webhookNodeNames.forEach((name: string) => { - const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] - ?.json as { headers?: { origin?: string } }; - if (execJson?.headers?.origin && execJson.headers.origin !== '') { - manualExecEventProperties.webhook_domain = pslGet( - execJson.headers.origin.replace(/^https?:\/\//, ''), - ); - } - }); - - this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); - } - } - } - - this.telemetry.trackWorkflowExecution(telemetryProperties); - } - onWorkflowSharingUpdate(workflowId: string, userId: string, userList: string[]) { const properties: ITelemetryTrackProperties = { workflow_id: workflowId, diff --git a/packages/cli/src/PublicApi/types.ts b/packages/cli/src/PublicApi/types.ts index f58b521e04a88..831dd03ebfb9c 100644 --- a/packages/cli/src/PublicApi/types.ts +++ b/packages/cli/src/PublicApi/types.ts @@ -142,6 +142,12 @@ export declare namespace CredentialRequest { >; type Delete = AuthenticatedRequest<{ id: string }, {}, {}, Record>; + + type Transfer = AuthenticatedRequest< + { workflowId: string }, + {}, + { destinationProjectId: string } + >; } export type OperationID = 'getUsers' | 'getUser'; diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts index 4da7635831340..57b6bd66f6841 100644 --- a/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/credentials.handler.ts @@ -19,6 +19,8 @@ import { toJsonSchema, } from './credentials.service'; import { Container } from 'typedi'; +import { z } from 'zod'; +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; export = { createCredential: [ @@ -44,6 +46,20 @@ export = { } }, ], + transferCredential: [ + projectScope('credential:move', 'credential'), + async (req: CredentialRequest.Transfer, res: express.Response) => { + const body = z.object({ destinationProjectId: z.string() }).parse(req.body); + + await Container.get(EnterpriseCredentialsService).transferOne( + req.user, + req.params.workflowId, + body.destinationProjectId, + ); + + res.status(204).send(); + }, + ], deleteCredential: [ projectScope('credential:delete', 'credential'), async ( diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml new file mode 100644 index 0000000000000..a9e9c5cf7c229 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/paths/credentials.id.transfer.yml @@ -0,0 +1,31 @@ +put: + x-eov-operation-id: transferCredential + x-eov-operation-handler: v1/handlers/credentials/credentials.handler + tags: + - Workflow + summary: Transfer a credential to another project. + description: Transfer a credential to another project. + parameters: + - $ref: '../schemas/parameters/credentialId.yml' + requestBody: + description: Destination project for the credential transfer. + content: + application/json: + schema: + type: object + properties: + destinationProjectId: + type: string + description: The ID of the project to transfer the credential to. + required: + - destinationProjectId + required: true + responses: + '200': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml new file mode 100644 index 0000000000000..f16676ce0b96c --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/credentials/spec/schemas/parameters/credentialId.yml @@ -0,0 +1,6 @@ +name: id +in: path +description: The ID of the credential. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts new file mode 100644 index 0000000000000..e61e808daf301 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/projects.handler.ts @@ -0,0 +1,65 @@ +import { globalScope, isLicensed, validCursor } from '../../shared/middlewares/global.middleware'; +import type { Response } from 'express'; +import type { ProjectRequest } from '@/requests'; +import type { PaginatedRequest } from '@/PublicApi/types'; +import Container from 'typedi'; +import { ProjectController } from '@/controllers/project.controller'; +import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { encodeNextCursor } from '../../shared/services/pagination.service'; + +type Create = ProjectRequest.Create; +type Update = ProjectRequest.Update; +type Delete = ProjectRequest.Delete; +type GetAll = PaginatedRequest; + +export = { + createProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:create'), + async (req: Create, res: Response) => { + const project = await Container.get(ProjectController).createProject(req); + + return res.status(201).json(project); + }, + ], + updateProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:update'), + async (req: Update, res: Response) => { + await Container.get(ProjectController).updateProject(req); + + return res.status(204).send(); + }, + ], + deleteProject: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:delete'), + async (req: Delete, res: Response) => { + await Container.get(ProjectController).deleteProject(req); + + return res.status(204).send(); + }, + ], + getProjects: [ + isLicensed('feat:projectRole:admin'), + globalScope('project:list'), + validCursor, + async (req: GetAll, res: Response) => { + const { offset = 0, limit = 100 } = req.query; + + const [projects, count] = await Container.get(ProjectRepository).findAndCount({ + skip: offset, + take: limit, + }); + + return res.json({ + data: projects, + nextCursor: encodeNextCursor({ + offset, + limit, + numberOfTotalRecords: count, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml new file mode 100644 index 0000000000000..a5aab19b3d6ed --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.projectId.yml @@ -0,0 +1,43 @@ +delete: + x-eov-operation-id: deleteProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Delete a project + description: Delete a project from your instance. + parameters: + - $ref: '../schemas/parameters/projectId.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' +put: + x-eov-operation-id: updateProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Project + summary: Update a project + description: Update a project. + requestBody: + description: Updated project object. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '204': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml new file mode 100644 index 0000000000000..1babd3dd6ea03 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/paths/projects.yml @@ -0,0 +1,40 @@ +post: + x-eov-operation-id: createProject + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Create a project + description: Create a project in your instance. + requestBody: + description: Payload for project to create. + content: + application/json: + schema: + $ref: '../schemas/project.yml' + required: true + responses: + '201': + description: Operation successful. + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' +get: + x-eov-operation-id: getProjects + x-eov-operation-handler: v1/handlers/projects/projects.handler + tags: + - Projects + summary: Retrieve projects + description: Retrieve projects from your instance. + parameters: + - $ref: '../../../../shared/spec/parameters/limit.yml' + - $ref: '../../../../shared/spec/parameters/cursor.yml' + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + $ref: '../schemas/projectList.yml' + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml new file mode 100644 index 0000000000000..32961f46016f9 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/parameters/projectId.yml @@ -0,0 +1,6 @@ +name: projectId +in: path +description: The ID of the project. +required: true +schema: + type: string diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml new file mode 100644 index 0000000000000..7a4d2ec432bdd --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/project.yml @@ -0,0 +1,13 @@ +type: object +additionalProperties: false +required: + - name +properties: + id: + type: string + readOnly: true + name: + type: string + type: + type: string + readOnly: true diff --git a/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml new file mode 100644 index 0000000000000..7d88be72fb008 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/projects/spec/schemas/projectList.yml @@ -0,0 +1,11 @@ +type: object +properties: + data: + type: array + items: + $ref: './project.yml' + nextCursor: + type: string + description: Paginate through projects 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 diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml new file mode 100644 index 0000000000000..92993adf7f9d4 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.role.yml @@ -0,0 +1,31 @@ +patch: + x-eov-operation-id: changeRole + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Change a user's global role + description: Change a user's global role + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + requestBody: + description: New role for the user + required: true + content: + application/json: + schema: + type: object + properties: + newRoleName: + type: string + enum: [global:admin, global:member] + required: + - newRoleName + responses: + '200': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml index 0d3c86c4cef97..f3dcae00534c4 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml @@ -17,3 +17,21 @@ get: $ref: '../schemas/user.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +delete: + x-eov-operation-id: deleteUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Delete a user + description: Delete a user from your instance. + parameters: + - $ref: '../schemas/parameters/userIdentifier.yml' + responses: + '204': + description: Operation successful. + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' + '404': + $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml index 1d618435531b5..7778a08e35c60 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml @@ -18,3 +18,53 @@ get: $ref: '../schemas/userList.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' +post: + x-eov-operation-id: createUser + x-eov-operation-handler: v1/handlers/users/users.handler.ee + tags: + - User + summary: Create multiple users + description: Create one or more users. + requestBody: + description: Array of users to be created. + required: true + content: + application/json: + schema: + type: array + items: + type: object + properties: + email: + type: string + format: email + role: + type: string + enum: [global:admin, global:member] + required: + - email + responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + user: + type: object + properties: + id: + type: string + email: + type: string + inviteAcceptUrl: + type: string + emailSent: + type: boolean + error: + type: string + '401': + $ref: '../../../../shared/spec/responses/unauthorized.yml' + '403': + $ref: '../../../../shared/spec/responses/forbidden.yml' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts index 0df22ec4aa9ef..cbfc43c0d100f 100644 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -6,11 +6,19 @@ import { clean, getAllUsersAndCount, getUser } from './users.service.ee'; import { encodeNextCursor } from '../../shared/services/pagination.service'; import { globalScope, + isLicensed, validCursor, validLicenseWithUserQuota, } from '../../shared/middlewares/global.middleware'; import type { UserRequest } from '@/requests'; import { InternalHooks } from '@/InternalHooks'; +import type { Response } from 'express'; +import { InvitationController } from '@/controllers/invitation.controller'; +import { UsersController } from '@/controllers/users.controller'; + +type Create = UserRequest.Invite; +type Delete = UserRequest.Delete; +type ChangeRole = UserRequest.ChangeRole; export = { getUser: [ @@ -68,4 +76,29 @@ export = { }); }, ], + createUser: [ + globalScope('user:create'), + async (req: Create, res: Response) => { + const usersInvited = await Container.get(InvitationController).inviteUser(req); + + return res.status(201).json(usersInvited); + }, + ], + deleteUser: [ + globalScope('user:delete'), + async (req: Delete, res: Response) => { + await Container.get(UsersController).deleteUser(req); + + return res.status(204).send(); + }, + ], + changeRole: [ + isLicensed('feat:advancedPermissions'), + globalScope('user:changeRole'), + async (req: ChangeRole, res: Response) => { + await Container.get(UsersController).changeGlobalRole(req); + + return res.status(204).send(); + }, + ], }; diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index fd3b286a17fb5..30c3a73bde0c7 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -32,6 +32,8 @@ tags: description: Operations about source control - name: Variables description: Operations about variables + - name: Projects + description: Operations about projects paths: /audit: @@ -60,18 +62,26 @@ paths: $ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml' /workflows/{id}/transfer: $ref: './handlers/workflows/spec/paths/workflows.id.transfer.yml' + /credentials/{id}/transfer: + $ref: './handlers/credentials/spec/paths/credentials.id.transfer.yml' /workflows/{id}/tags: $ref: './handlers/workflows/spec/paths/workflows.id.tags.yml' /users: $ref: './handlers/users/spec/paths/users.yml' /users/{id}: $ref: './handlers/users/spec/paths/users.id.yml' + /users/{id}/role: + $ref: './handlers/users/spec/paths/users.id.role.yml' /source-control/pull: $ref: './handlers/sourceControl/spec/paths/sourceControl.yml' /variables: $ref: './handlers/variables/spec/paths/variables.yml' /variables/{id}: $ref: './handlers/variables/spec/paths/variables.id.yml' + /projects: + $ref: './handlers/projects/spec/paths/projects.yml' + /projects/{projectId}: + $ref: './handlers/projects/spec/paths/projects.projectId.yml' components: schemas: $ref: './shared/spec/schemas/_index.yml' diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index f4acd61b6aa55..9538ec91988fd 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -52,7 +52,6 @@ import { Push } from '@/push'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { SecretsHelper } from './SecretsHelpers'; @@ -548,7 +547,6 @@ function hookFunctionsSave(): IWorkflowExecuteHooks { */ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { const logger = Container.get(Logger); - const internalHooks = Container.get(InternalHooks); const workflowStatisticsService = Container.get(WorkflowStatisticsService); const eventService = Container.get(EventService); return { @@ -644,13 +642,9 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { async function (this: WorkflowHooks, runData: IRun): Promise { const { executionId, workflowData: workflow } = this; - void internalHooks.onWorkflowPostExecute(executionId, workflow, runData); eventService.emit('workflow-post-execute', { - workflowId: workflow.id, - workflowName: workflow.name, + workflow, executionId, - success: runData.status === 'success', - isManual: runData.mode === 'manual', runData, }); }, @@ -787,7 +781,6 @@ async function executeWorkflow( parentCallbackManager?: CallbackManager; }, ): Promise | IWorkflowExecuteProcess> { - const internalHooks = Container.get(InternalHooks); const externalHooks = Container.get(ExternalHooks); await externalHooks.init(); @@ -933,13 +926,9 @@ async function executeWorkflow( await externalHooks.run('workflow.postExecute', [data, workflowData, executionId]); - void internalHooks.onWorkflowPostExecute(executionId, workflowData, data, additionalData.userId); eventService.emit('workflow-post-execute', { - workflowId: workflowData.id, - workflowName: workflowData.name, + workflow: workflowData, executionId, - success: data.status === 'success', - isManual: data.mode === 'manual', userId: additionalData.userId, runData: data, }); diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 3318dd283cd60..f51a44cc4d2a3 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -34,7 +34,6 @@ import * as WorkflowHelpers from '@/WorkflowHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; -import { InternalHooks } from '@/InternalHooks'; import { Logger } from '@/Logger'; import { WorkflowStaticDataService } from '@/workflows/workflowStaticData.service'; import { EventService } from './eventbus/event.service'; @@ -160,18 +159,9 @@ export class WorkflowRunner { const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); postExecutePromise .then(async (executionData) => { - void Container.get(InternalHooks).onWorkflowPostExecute( - executionId, - data.workflowData, - executionData, - data.userId, - ); this.eventService.emit('workflow-post-execute', { - workflowId: data.workflowData.id, - workflowName: data.workflowData.name, + workflow: data.workflowData, executionId, - success: executionData?.status === 'success', - isManual: data.executionMode === 'manual', userId: data.userId, runData: executionData, }); diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 758eeb5ae590f..eeb868798bf6b 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -52,6 +52,8 @@ export interface MessageEventBusInitializeOptions { } @Service() +// TODO: Convert to TypedEventEmitter +// eslint-disable-next-line n8n-local-rules/no-type-unsafe-event-emitter export class MessageEventBus extends EventEmitter { private isInitialized = false; diff --git a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts index 52b86b58e1b6b..44277f1de4d0d 100644 --- a/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts +++ b/packages/cli/src/eventbus/__tests__/audit-event-relay.service.test.ts @@ -141,27 +141,31 @@ describe('AuditEventRelay', () => { it('should log on `workflow-post-execute` for successful execution', () => { const payload = mock({ executionId: 'some-id', - success: true, userId: 'some-id', - workflowId: 'some-id', - isManual: true, - workflowName: 'some-name', - metadata: {}, - runData: mock({ data: { resultData: {} } }), + workflow: mock({ id: 'some-id', name: 'some-name' }), + runData: mock({ status: 'success', mode: 'manual', data: { resultData: {} } }), }); eventService.emit('workflow-post-execute', payload); - const { runData: _, ...rest } = payload; + const { runData: _, workflow: __, ...rest } = payload; expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ eventName: 'n8n.workflow.success', - payload: rest, + payload: { + ...rest, + success: true, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', + }, }); }); - it('should handle `workflow-post-execute` event for unsuccessful execution', () => { + it('should log on `workflow-post-execute` event for unsuccessful execution', () => { const runData = mock({ + status: 'error', + mode: 'manual', data: { resultData: { lastNodeExecuted: 'some-node', @@ -177,23 +181,23 @@ describe('AuditEventRelay', () => { const event = { executionId: 'some-id', - success: false, userId: 'some-id', - workflowId: 'some-id', - isManual: true, - workflowName: 'some-name', - metadata: {}, + workflow: mock({ id: 'some-id', name: 'some-name' }), runData, }; eventService.emit('workflow-post-execute', event); - const { runData: _, ...rest } = event; + const { runData: _, workflow: __, ...rest } = event; expect(eventBus.sendWorkflowEvent).toHaveBeenCalledWith({ eventName: 'n8n.workflow.failed', payload: { ...rest, + success: false, + isManual: true, + workflowName: 'some-name', + workflowId: 'some-id', lastNodeExecuted: 'some-node', errorNodeType: 'some-type', errorMessage: 'some-message', diff --git a/packages/cli/src/eventbus/audit-event-relay.service.ts b/packages/cli/src/eventbus/audit-event-relay.service.ts index f8a95a3ebf359..dcefeac0bd317 100644 --- a/packages/cli/src/eventbus/audit-event-relay.service.ts +++ b/packages/cli/src/eventbus/audit-event-relay.service.ts @@ -122,12 +122,20 @@ export class AuditEventRelay { } private workflowPostExecute(event: Event['workflow-post-execute']) { - const { runData, ...rest } = event; + const { runData, workflow, ...rest } = event; - if (event.success) { + const payload = { + ...rest, + success: runData?.status === 'success', + isManual: runData?.mode === 'manual', + workflowId: workflow.id, + workflowName: workflow.name, + }; + + if (payload.success) { void this.eventBus.sendWorkflowEvent({ eventName: 'n8n.workflow.success', - payload: rest, + payload, }); return; @@ -136,7 +144,7 @@ export class AuditEventRelay { void this.eventBus.sendWorkflowEvent({ eventName: 'n8n.workflow.failed', payload: { - ...rest, + ...payload, lastNodeExecuted: runData?.data.resultData.lastNodeExecuted, errorNodeType: runData?.data.resultData.error && 'node' in runData?.data.resultData.error diff --git a/packages/cli/src/eventbus/event.types.ts b/packages/cli/src/eventbus/event.types.ts index b62d3bc141031..bcca98b919637 100644 --- a/packages/cli/src/eventbus/event.types.ts +++ b/packages/cli/src/eventbus/event.types.ts @@ -44,12 +44,8 @@ export type Event = { 'workflow-post-execute': { executionId: string; - success: boolean; userId?: string; - workflowId: string; - isManual: boolean; - workflowName: string; - metadata?: Record; + workflow: IWorkflowBase; runData?: IRun; }; diff --git a/packages/cli/src/executions/execution-recovery.service.ts b/packages/cli/src/executions/execution-recovery.service.ts index b72fc490dddbb..615c65ea6ef5f 100644 --- a/packages/cli/src/executions/execution-recovery.service.ts +++ b/packages/cli/src/executions/execution-recovery.service.ts @@ -3,7 +3,6 @@ import { Push } from '@/push'; import { jsonStringify, sleep } from 'n8n-workflow'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; // @TODO: Dependency cycle -import { InternalHooks } from '@/InternalHooks'; // @TODO: Dependency cycle if injected import type { DateTime } from 'luxon'; import type { IRun, ITaskData } from 'n8n-workflow'; import type { EventMessageTypes } from '../eventbus/EventMessageClasses'; @@ -280,22 +279,9 @@ export class ExecutionRecoveryService { private async runHooks(execution: IExecutionResponse) { execution.data ??= { resultData: { runData: {} } }; - await Container.get(InternalHooks).onWorkflowPostExecute(execution.id, execution.workflowData, { - data: execution.data, - finished: false, - mode: execution.mode, - waitTill: execution.waitTill, - startedAt: execution.startedAt, - stoppedAt: execution.stoppedAt, - status: execution.status, - }); - this.eventService.emit('workflow-post-execute', { - workflowId: execution.workflowData.id, - workflowName: execution.workflowData.name, + workflow: execution.workflowData, executionId: execution.id, - success: execution.status === 'success', - isManual: execution.mode === 'manual', runData: execution, }); diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 1c0ca8c0d68ed..945f3650ea340 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -1,6 +1,8 @@ import { Get, Post, RestController, GlobalScope } from '@/decorators'; import { AuthenticatedRequest, LicenseRequest } from '@/requests'; import { LicenseService } from './license.service'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { AxiosError } from 'axios'; @RestController('/license') export class LicenseController { @@ -14,7 +16,18 @@ export class LicenseController { @Post('/enterprise/request_trial') @GlobalScope('license:manage') async requestEnterpriseTrial(req: AuthenticatedRequest) { - await this.licenseService.requestEnterpriseTrial(req.user); + try { + await this.licenseService.requestEnterpriseTrial(req.user); + } catch (error: unknown) { + if (error instanceof Error) { + const errorMsg = + (error as AxiosError<{ message: string }>).response?.data?.message ?? error.message; + + throw new BadRequestError(errorMsg); + } else { + throw new BadRequestError('Failed to request trial'); + } + } } @Post('/activate') diff --git a/packages/cli/src/telemetry/telemetry-event-relay.service.ts b/packages/cli/src/telemetry/telemetry-event-relay.service.ts index 9077abdf9ce23..f92b38987b1e3 100644 --- a/packages/cli/src/telemetry/telemetry-event-relay.service.ts +++ b/packages/cli/src/telemetry/telemetry-event-relay.service.ts @@ -8,10 +8,14 @@ import { License } from '@/License'; import { GlobalConfig } from '@n8n/config'; import { N8N_VERSION } from '@/constants'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import type { ExecutionStatus, INodesGraphResult, ITelemetryTrackProperties } from 'n8n-workflow'; +import { get as pslGet } from 'psl'; import { TelemetryHelpers } from 'n8n-workflow'; import { NodeTypes } from '@/NodeTypes'; import { SharedWorkflowRepository } from '@/databases/repositories/sharedWorkflow.repository'; import { ProjectRelationRepository } from '@/databases/repositories/projectRelation.repository'; +import type { IExecutionTrackProperties } from '@/Interfaces'; +import { determineFinalExecutionStatus } from '@/executionLifecycleHooks/shared/sharedHookFunctions'; @Service() export class TelemetryEventRelay { @@ -118,6 +122,9 @@ export class TelemetryEventRelay { this.eventService.on('workflow-saved', async (event) => { await this.workflowSaved(event); }); + this.eventService.on('workflow-post-execute', async (event) => { + await this.workflowPostExecute(event); + }); } private teamProjectUpdated({ userId, role, members, projectId }: Event['team-project-updated']) { @@ -584,4 +591,138 @@ export class TelemetryEventRelay { earliest_workflow_created: firstWorkflow?.createdAt, }); } + + // eslint-disable-next-line complexity + private async workflowPostExecute({ workflow, runData, userId }: Event['workflow-post-execute']) { + if (!workflow.id) { + return; + } + + if (runData?.status === 'waiting') { + // No need to send telemetry or logs when the workflow hasn't finished yet. + return; + } + + const telemetryProperties: IExecutionTrackProperties = { + workflow_id: workflow.id, + is_manual: false, + version_cli: N8N_VERSION, + success: false, + }; + + if (userId) { + telemetryProperties.user_id = userId; + } + + if (runData?.data.resultData.error?.message?.includes('canceled')) { + runData.status = 'canceled'; + } + + telemetryProperties.success = !!runData?.finished; + + // const executionStatus: ExecutionStatus = runData?.status ?? 'unknown'; + const executionStatus: ExecutionStatus = runData + ? determineFinalExecutionStatus(runData) + : 'unknown'; + + if (runData !== undefined) { + telemetryProperties.execution_mode = runData.mode; + telemetryProperties.is_manual = runData.mode === 'manual'; + + let nodeGraphResult: INodesGraphResult | null = null; + + if (!telemetryProperties.success && runData?.data.resultData.error) { + telemetryProperties.error_message = runData?.data.resultData.error.message; + let errorNodeName = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.name + : undefined; + telemetryProperties.error_node_type = + 'node' in runData?.data.resultData.error + ? runData?.data.resultData.error.node?.type + : undefined; + + if (runData.data.resultData.lastNodeExecuted) { + const lastNode = TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.resultData.lastNodeExecuted, + ); + + if (lastNode !== undefined) { + telemetryProperties.error_node_type = lastNode.type; + errorNodeName = lastNode.name; + } + } + + if (telemetryProperties.is_manual) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + telemetryProperties.node_graph = nodeGraphResult.nodeGraph; + telemetryProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + + if (errorNodeName) { + telemetryProperties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; + } + } + } + + if (telemetryProperties.is_manual) { + if (!nodeGraphResult) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + } + + let userRole: 'owner' | 'sharee' | undefined = undefined; + if (userId) { + const role = await this.sharedWorkflowRepository.findSharingRole(userId, workflow.id); + if (role) { + userRole = role === 'workflow:owner' ? 'owner' : 'sharee'; + } + } + + const manualExecEventProperties: ITelemetryTrackProperties = { + user_id: userId, + workflow_id: workflow.id, + status: executionStatus, + executionStatus: runData?.status ?? 'unknown', + error_message: telemetryProperties.error_message as string, + error_node_type: telemetryProperties.error_node_type, + node_graph_string: telemetryProperties.node_graph_string as string, + error_node_id: telemetryProperties.error_node_id as string, + webhook_domain: null, + sharing_role: userRole, + }; + + if (!manualExecEventProperties.node_graph_string) { + nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow, this.nodeTypes); + manualExecEventProperties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + } + + if (runData.data.startData?.destinationNode) { + const telemetryPayload = { + ...manualExecEventProperties, + node_type: TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.startData?.destinationNode, + )?.type, + node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], + }; + + this.telemetry.track('Manual node exec finished', telemetryPayload); + } else { + nodeGraphResult.webhookNodeNames.forEach((name: string) => { + const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] + ?.json as { headers?: { origin?: string } }; + if (execJson?.headers?.origin && execJson.headers.origin !== '') { + manualExecEventProperties.webhook_domain = pslGet( + execJson.headers.origin.replace(/^https?:\/\//, ''), + ); + } + }); + + this.telemetry.track('Manual workflow exec finished', manualExecEventProperties); + } + } + } + + this.telemetry.trackWorkflowExecution(telemetryProperties); + } } diff --git a/packages/cli/test/integration/prometheus-metrics.test.ts b/packages/cli/test/integration/prometheus-metrics.test.ts index 4c00a98f74a49..9f6a0fad6e89d 100644 --- a/packages/cli/test/integration/prometheus-metrics.test.ts +++ b/packages/cli/test/integration/prometheus-metrics.test.ts @@ -5,45 +5,27 @@ import request, { type Response } from 'supertest'; import { N8N_VERSION } from '@/constants'; import { PrometheusMetricsService } from '@/metrics/prometheus-metrics.service'; import { setupTestServer } from './shared/utils'; -import { mockInstance } from '@test/mocking'; import { GlobalConfig } from '@n8n/config'; jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); const toLines = (response: Response) => response.text.trim().split('\n'); -mockInstance(GlobalConfig, { - database: { - type: 'sqlite', - sqlite: { - database: 'database.sqlite', - enableWAL: false, - executeVacuumOnStartup: false, - poolSize: 0, - }, - logging: { - enabled: false, - maxQueryExecutionTime: 0, - options: 'error', - }, - tablePrefix: '', - }, - endpoints: { - metrics: { - prefix: 'n8n_test_', - includeDefaultMetrics: true, - includeApiEndpoints: true, - includeCacheMetrics: true, - includeMessageEventBusMetrics: true, - includeCredentialTypeLabel: false, - includeNodeTypeLabel: false, - includeWorkflowIdLabel: false, - includeApiPathLabel: true, - includeApiMethodLabel: true, - includeApiStatusCodeLabel: true, - }, - }, -}); +const globalConfig = Container.get(GlobalConfig); +// @ts-expect-error `metrics` is a readonly property +globalConfig.endpoints.metrics = { + prefix: 'n8n_test_', + includeDefaultMetrics: true, + includeApiEndpoints: true, + includeCacheMetrics: true, + includeMessageEventBusMetrics: true, + includeCredentialTypeLabel: false, + includeNodeTypeLabel: false, + includeWorkflowIdLabel: false, + includeApiPathLabel: true, + includeApiMethodLabel: true, + includeApiStatusCodeLabel: true, +}; const server = setupTestServer({ endpointGroups: ['metrics'] }); const agent = request.agent(server.app); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index dfa6c44dd4037..b5ed2bfb31d82 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -9,9 +9,10 @@ import { randomApiKey, randomName } from '../shared/random'; import * as utils from '../shared/utils/'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; -import { affixRoleToSaveCredential } from '../shared/db/credentials'; +import { affixRoleToSaveCredential, createCredentials } from '../shared/db/credentials'; import { addApiKey, createUser, createUserShell } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; +import { createTeamProject } from '@test-integration/db/projects'; let owner: User; let member: User; @@ -256,6 +257,53 @@ describe('GET /credentials/schema/:credentialType', () => { }); }); +describe('PUT /credentials/:id/transfer', () => { + test('should transfer credential to project', async () => { + /** + * Arrange + */ + const [firstProject, secondProject] = await Promise.all([ + createTeamProject('first-project', owner), + createTeamProject('second-project', owner), + ]); + + const credentials = await createCredentials( + { name: 'Test', type: 'test', data: '' }, + firstProject, + ); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({ + destinationProjectId: secondProject.id, + }); + + /** + * Assert + */ + expect(response.statusCode).toBe(204); + }); + + test('if no destination project, should reject', async () => { + /** + * Arrange + */ + const project = await createTeamProject('first-project', member); + const credentials = await createCredentials({ name: 'Test', type: 'test', data: '' }, project); + + /** + * Act + */ + const response = await authOwnerAgent.put(`/credentials/${credentials.id}/transfer`).send({}); + + /** + * Assert + */ + expect(response.statusCode).toBe(400); + }); +}); + const credentialPayload = (): CredentialPayload => ({ name: randomName(), type: 'githubApi', diff --git a/packages/cli/test/integration/publicApi/projects.test.ts b/packages/cli/test/integration/publicApi/projects.test.ts new file mode 100644 index 0000000000000..0554fd6f4116f --- /dev/null +++ b/packages/cli/test/integration/publicApi/projects.test.ts @@ -0,0 +1,401 @@ +import { setupTestServer } from '@test-integration/utils'; +import { createMember, createOwner } from '@test-integration/db/users'; +import * as testDb from '../shared/testDb'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; +import { createTeamProject, getProjectByNameOrFail } from '@test-integration/db/projects'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; + +describe('Projects in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['Project', 'User']); + }); + + describe('GET /projects', () => { + it('if licensed, should return all projects with pagination', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projects = await Promise.all([ + createTeamProject(), + createTeamProject(), + createTeamProject(), + ]); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('data'); + expect(response.body).toHaveProperty('nextCursor'); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBe(projects.length + 1); // +1 for the owner's personal project + + projects.forEach(({ id, name }) => { + expect(response.body.data).toContainEqual(expect.objectContaining({ id, name })); + }); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createMember({ withApiKey: true }); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).get('/projects'); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('POST /projects', () => { + it('if licensed, should create a new project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(201); + expect(response.body).toEqual({ + name: 'some-project', + type: 'team', + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + role: 'project:admin', + scopes: expect.any(Array), + }); + await expect(getProjectByNameOrFail(projectPayload.name)).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const projectPayload = { name: 'some-project' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .post('/projects') + .send(projectPayload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('DELETE /projects/:id', () => { + it('if licensed, should delete a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail(project.id)).rejects.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).delete(`/projects/${project.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); + + describe('PUT /projects/:id', () => { + it('if licensed, should update a project', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject('old-name'); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getProjectByNameOrFail('new-name')).resolves.not.toThrow(); + }); + + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:projectRole:admin').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const member = await createMember({ withApiKey: true }); + const project = await createTeamProject(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(member) + .put(`/projects/${project.id}`) + .send({ name: 'new-name' }); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/users.test.ts b/packages/cli/test/integration/publicApi/users.test.ts new file mode 100644 index 0000000000000..6021ae01a35f5 --- /dev/null +++ b/packages/cli/test/integration/publicApi/users.test.ts @@ -0,0 +1,252 @@ +import { setupTestServer } from '@test-integration/utils'; +import * as testDb from '../shared/testDb'; +import { createMember, createOwner, getUserById } from '@test-integration/db/users'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; + +describe('Users in Public API', () => { + const testServer = setupTestServer({ endpointGroups: ['publicApi'] }); + mockInstance(Telemetry); + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + await testDb.truncate(['User']); + }); + + describe('POST /users', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const payload = { email: 'test@test.com', role: 'global:admin' }; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const member = await createMember({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(member).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should create a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const payload = [{ email: 'test@test.com', role: 'global:admin' }]; + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).post('/users').send(payload); + + /** + * Assert + */ + expect(response.status).toBe(201); + + expect(response.body).toHaveLength(1); + + const [result] = response.body; + const { user: returnedUser, error } = result; + const payloadUser = payload[0]; + + expect(returnedUser).toHaveProperty('email', payload[0].email); + expect(typeof returnedUser.inviteAcceptUrl).toBe('string'); + expect(typeof returnedUser.emailSent).toBe('boolean'); + expect(error).toBe(''); + + const storedUser = await getUserById(returnedUser.id); + expect(returnedUser.id).toBe(storedUser.id); + expect(returnedUser.email).toBe(storedUser.email); + expect(returnedUser.email).toBe(payloadUser.email); + expect(storedUser.role).toBe(payloadUser.role); + }); + }); + + describe('DELETE /users/:id', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .delete(`/users/${secondMember.id}`); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should delete a user', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).delete(`/users/${member.id}`); + + /** + * Assert + */ + expect(response.status).toBe(204); + await expect(getUserById(member.id)).rejects.toThrow(); + }); + }); + + describe('PATCH /users/:id/role', () => { + it('if not authenticated, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: false }); + const member = await createMember(); + + /** + * Act + */ + const response = await testServer.publicApiAgentFor(owner).patch(`/users/${member.id}/role`); + + /** + * Assert + */ + expect(response.status).toBe(401); + }); + + it('if not licensed, should reject', async () => { + /** + * Arrange + */ + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty( + 'message', + new FeatureNotLicensedError('feat:advancedPermissions').message, + ); + }); + + it('if missing scope, should reject', async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const firstMember = await createMember({ withApiKey: true }); + const secondMember = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(firstMember) + .patch(`/users/${secondMember.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it("should change a user's role", async () => { + /** + * Arrange + */ + testServer.license.enable('feat:advancedPermissions'); + const owner = await createOwner({ withApiKey: true }); + const member = await createMember(); + const payload = { newRoleName: 'global:admin' }; + + /** + * Act + */ + const response = await testServer + .publicApiAgentFor(owner) + .patch(`/users/${member.id}/role`) + .send(payload); + + /** + * Assert + */ + expect(response.status).toBe(204); + const storedUser = await getUserById(member.id); + expect(storedUser.role).toBe(payload.newRoleName); + }); + }); +}); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 855e69e3df12a..1e292af64d92f 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -1493,7 +1493,7 @@ describe('PUT /workflows/:id/transfer', () => { * Arrange */ const firstProject = await createTeamProject('first-project', member); - const secondProject = await createTeamProject('secon-project', member); + const secondProject = await createTeamProject('second-project', member); const workflow = await createWorkflow({}, firstProject); /** diff --git a/packages/cli/test/integration/shared/db/credentials.ts b/packages/cli/test/integration/shared/db/credentials.ts index 046d27db261a0..588fee6b51196 100644 --- a/packages/cli/test/integration/shared/db/credentials.ts +++ b/packages/cli/test/integration/shared/db/credentials.ts @@ -38,11 +38,24 @@ export async function createManyCredentials( ); } -export async function createCredentials(attributes: Partial = emptyAttributes) { +export async function createCredentials( + attributes: Partial = emptyAttributes, + project?: Project, +) { const credentialsRepository = Container.get(CredentialsRepository); - const entity = credentialsRepository.create(attributes); + const credentials = await credentialsRepository.save(credentialsRepository.create(attributes)); + + if (project) { + await Container.get(SharedCredentialsRepository).save( + Container.get(SharedCredentialsRepository).create({ + project, + credentials, + role: 'credential:owner', + }), + ); + } - return await credentialsRepository.save(entity); + return credentials; } /** diff --git a/packages/cli/test/integration/shared/db/projects.ts b/packages/cli/test/integration/shared/db/projects.ts index 60548575b362b..3de7de5bb95f9 100644 --- a/packages/cli/test/integration/shared/db/projects.ts +++ b/packages/cli/test/integration/shared/db/projects.ts @@ -34,6 +34,10 @@ export const linkUserToProject = async (user: User, project: Project, role: Proj ); }; +export async function getProjectByNameOrFail(name: string) { + return await Container.get(ProjectRepository).findOneOrFail({ where: { name } }); +} + export const getPersonalProject = async (user: User): Promise => { return await Container.get(ProjectRepository).findOneOrFail({ where: { diff --git a/packages/cli/test/integration/shared/db/users.ts b/packages/cli/test/integration/shared/db/users.ts index f125f5ccded4f..98626bc549d9e 100644 --- a/packages/cli/test/integration/shared/db/users.ts +++ b/packages/cli/test/integration/shared/db/users.ts @@ -86,7 +86,11 @@ export async function createOwner({ withApiKey } = { withApiKey: false }) { return await createUser({ role: 'global:owner' }); } -export async function createMember() { +export async function createMember({ withApiKey } = { withApiKey: false }) { + if (withApiKey) { + return await addApiKey(await createUser({ role: 'global:member' })); + } + return await createUser({ role: 'global:member' }); } diff --git a/packages/editor-ui/src/components/CredentialCard.test.ts b/packages/editor-ui/src/components/CredentialCard.test.ts index 06760c75da7c5..9ca589923d59e 100644 --- a/packages/editor-ui/src/components/CredentialCard.test.ts +++ b/packages/editor-ui/src/components/CredentialCard.test.ts @@ -6,7 +6,7 @@ import { createComponentRenderer } from '@/__tests__/render'; import CredentialCard from '@/components/CredentialCard.vue'; import type { ICredentialsResponse } from '@/Interface'; import type { ProjectSharingData } from '@/types/projects.types'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useProjectsStore } from '@/stores/projects.store'; const renderComponent = createComponentRenderer(CredentialCard); @@ -22,12 +22,12 @@ const createCredential = (overrides = {}): ICredentialsResponse => ({ }); describe('CredentialCard', () => { - let settingsStore: ReturnType; + let projectsStore: ReturnType; beforeEach(() => { const pinia = createTestingPinia(); setActivePinia(pinia); - settingsStore = useSettingsStore(); + projectsStore = useProjectsStore(); }); it('should render name and home project name', () => { @@ -63,7 +63,7 @@ describe('CredentialCard', () => { }); it('should show Move action only if there is resource permission and not on community plan', async () => { - vi.spyOn(settingsStore, 'isCommunityPlan', 'get').mockReturnValue(false); + vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); const data = createCredential({ scopes: ['credential:move'], diff --git a/packages/editor-ui/src/components/CredentialCard.vue b/packages/editor-ui/src/components/CredentialCard.vue index f36926a19b09d..d9ac12ffd9a54 100644 --- a/packages/editor-ui/src/components/CredentialCard.vue +++ b/packages/editor-ui/src/components/CredentialCard.vue @@ -14,7 +14,6 @@ import { useProjectsStore } from '@/stores/projects.store'; import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue'; import { useI18n } from '@/composables/useI18n'; import { ResourceType } from '@/utils/projects.utils'; -import { useSettingsStore } from '@/stores/settings.store'; const CREDENTIAL_LIST_ITEM_ACTIONS = { OPEN: 'open', @@ -46,7 +45,6 @@ const message = useMessage(); const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); const projectsStore = useProjectsStore(); -const settingsStore = useSettingsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.credential').toLowerCase()); const credentialType = computed(() => credentialsStore.getCredentialTypeByName(props.data.type)); @@ -66,7 +64,7 @@ const actions = computed(() => { }); } - if (credentialPermissions.value.move && !settingsStore.isCommunityPlan) { + if (credentialPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { items.push({ label: locale.baseText('credentials.item.move'), value: CREDENTIAL_LIST_ITEM_ACTIONS.MOVE, diff --git a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue index d1f5bac9ce60a..2b4909e0f2f95 100644 --- a/packages/editor-ui/src/components/Projects/ProjectNavigation.vue +++ b/packages/editor-ui/src/components/Projects/ProjectNavigation.vue @@ -116,7 +116,7 @@ onMounted(async () => {
@@ -137,7 +137,9 @@ onMounted(async () => { @@ -171,7 +173,7 @@ onMounted(async () => {
diff --git a/packages/editor-ui/src/components/WorkflowCard.test.ts b/packages/editor-ui/src/components/WorkflowCard.test.ts index 97be0e7fd4536..735b20ada5f0b 100644 --- a/packages/editor-ui/src/components/WorkflowCard.test.ts +++ b/packages/editor-ui/src/components/WorkflowCard.test.ts @@ -7,7 +7,7 @@ import { VIEWS } from '@/constants'; import WorkflowCard from '@/components/WorkflowCard.vue'; import type { IWorkflowDb } from '@/Interface'; import { useRouter } from 'vue-router'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useProjectsStore } from '@/stores/projects.store'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -40,13 +40,13 @@ describe('WorkflowCard', () => { let pinia: ReturnType; let windowOpenSpy: MockInstance; let router: ReturnType; - let settingsStore: ReturnType; + let projectsStore: ReturnType; beforeEach(async () => { pinia = createPinia(); setActivePinia(pinia); router = useRouter(); - settingsStore = useSettingsStore(); + projectsStore = useProjectsStore(); windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); }); @@ -143,8 +143,8 @@ describe('WorkflowCard', () => { expect(badge).toHaveTextContent('John Doe'); }); - it('should show Move action only if there is resource permission and not on community plan', async () => { - vi.spyOn(settingsStore, 'isCommunityPlan', 'get').mockReturnValue(false); + it('should show Move action only if there is resource permission and team projects available', async () => { + vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); const data = createWorkflow({ scopes: ['workflow:move'], diff --git a/packages/editor-ui/src/components/WorkflowCard.vue b/packages/editor-ui/src/components/WorkflowCard.vue index 21220e94924dc..6cc73db627817 100644 --- a/packages/editor-ui/src/components/WorkflowCard.vue +++ b/packages/editor-ui/src/components/WorkflowCard.vue @@ -95,7 +95,7 @@ const actions = computed(() => { }); } - if (workflowPermissions.value.move && !settingsStore.isCommunityPlan) { + if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) { items.push({ label: locale.baseText('workflows.item.move'), value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE, diff --git a/packages/editor-ui/src/stores/projects.store.ts b/packages/editor-ui/src/stores/projects.store.ts index f5fbaf6200762..6bc61d246c7c9 100644 --- a/packages/editor-ui/src/stores/projects.store.ts +++ b/packages/editor-ui/src/stores/projects.store.ts @@ -49,19 +49,19 @@ export const useProjectsStore = defineStore('projects', () => { ); const teamProjects = computed(() => projects.value.filter((p) => p.type === ProjectTypes.Team)); const teamProjectsLimit = computed(() => settingsStore.settings.enterprise.projects.team.limit); - const teamProjectsAvailable = computed( + const isTeamProjectFeatureEnabled = computed( () => settingsStore.settings.enterprise.projects.team.limit !== 0, ); const hasUnlimitedProjects = computed( () => settingsStore.settings.enterprise.projects.team.limit === -1, ); - const teamProjectLimitExceeded = computed( + const isTeamProjectLimitExceeded = computed( () => projectsCount.value.team >= teamProjectsLimit.value, ); const canCreateProjects = computed( () => hasUnlimitedProjects.value || - (teamProjectsAvailable.value && !teamProjectLimitExceeded.value), + (isTeamProjectFeatureEnabled.value && !isTeamProjectLimitExceeded.value), ); const hasPermissionToCreateProjects = computed(() => hasPermission(['rbac'], { rbac: { scope: 'project:create' } }), @@ -199,7 +199,7 @@ export const useProjectsStore = defineStore('projects', () => { hasUnlimitedProjects, canCreateProjects, hasPermissionToCreateProjects, - teamProjectsAvailable, + isTeamProjectFeatureEnabled, projectNavActiveId, setCurrentProject, getAllProjects, diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 179ae29311c11..77348f2a945e1 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -39,7 +39,7 @@ "@types/xml2js": "catalog:" }, "dependencies": { - "@n8n/tournament": "1.0.2", + "@n8n/tournament": "1.0.3", "@n8n_io/riot-tmpl": "4.0.0", "ast-types": "0.15.2", "axios": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c8ff04428456..8287287e22db9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1710,8 +1710,8 @@ importers: packages/workflow: dependencies: '@n8n/tournament': - specifier: 1.0.2 - version: 1.0.2 + specifier: 1.0.3 + version: 1.0.3 '@n8n_io/riot-tmpl': specifier: 4.0.0 version: 4.0.0 @@ -4143,8 +4143,8 @@ packages: resolution: {integrity: sha512-rbnMnSdEwq2yuYMgzOQ4jTXm+oH7yjN/0ISfB/7O6pUcEPsZt9UW60BYfQ1WWHkKa/evI8vgER2zV5/RC1BupQ==} engines: {node: '>=18.10'} - '@n8n/tournament@1.0.2': - resolution: {integrity: sha512-fTpi7F8ra5flGSVfRzohPyG7czAAKCZPlLjdKdwbLJivLoI/Ekhgodov1jfVSCVFVbwQ06gRQRxLEDzl2jl8ig==} + '@n8n/tournament@1.0.3': + resolution: {integrity: sha512-GnmDD5wKAxKfxnSzhENHPn5n91/1c3/psnuT7D+jHHVQdMe8qaCcSq15rcGRfDfTf2v+BZBT0yeyK8Cfexr9yw==} engines: {node: '>=18.10', pnpm: '>=8.6'} '@n8n/typeorm@0.3.20-10': @@ -6231,9 +6231,6 @@ packages: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} - assert@2.0.0: - resolution: {integrity: sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==} - assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} @@ -7557,9 +7554,6 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} - es6-object-assign@1.1.0: - resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} - es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -16649,7 +16643,7 @@ snapshots: '@types/retry': 0.12.5 retry: 0.13.1 - '@n8n/tournament@1.0.2': + '@n8n/tournament@1.0.3': dependencies: '@n8n_io/riot-tmpl': 4.0.1 ast-types: 0.16.1 @@ -19612,13 +19606,6 @@ snapshots: assert-plus@1.0.0: {} - assert@2.0.0: - dependencies: - es6-object-assign: 1.1.0 - is-nan: 1.3.2 - object-is: 1.1.5 - util: 0.12.5 - assert@2.1.0: dependencies: call-bind: 1.0.7 @@ -21208,8 +21195,6 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 - es6-object-assign@1.1.0: {} - es6-promise@3.3.1: {} esbuild-plugin-alias@0.2.1: {} @@ -25579,7 +25564,7 @@ snapshots: recast@0.22.0: dependencies: - assert: 2.0.0 + assert: 2.1.0 ast-types: 0.15.2 esprima: 4.0.1 source-map: 0.6.1