diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 791d608d7f..10295fc6d9 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -47,6 +47,7 @@ export { GenerateCredentialNameRequestQuery } from './credentials/generate-crede export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto'; export { ManualRunQueryDto } from './workflows/manual-run-query.dto'; +export { TransferWorkflowBodyDto } from './workflows/transfer.dto'; export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/__tests__/transfer-workflow.dto.test.ts b/packages/@n8n/api-types/src/dto/workflows/__tests__/transfer-workflow.dto.test.ts new file mode 100644 index 0000000000..eddce58ab4 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/__tests__/transfer-workflow.dto.test.ts @@ -0,0 +1,56 @@ +import { TransferWorkflowBodyDto } from '../transfer.dto'; + +describe('ImportWorkflowFromUrlDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'only destinationProjectId', + input: { destinationProjectId: '1234' }, + }, + { + name: 'destinationProjectId with empty shareCredentials', + input: { destinationProjectId: '1234', shareCredentials: [] }, + }, + { + name: 'destinationProjectId with shareCredentials', + input: { destinationProjectId: '1234', shareCredentials: ['1235'] }, + }, + ])('should validate $name', ({ input }) => { + const result = TransferWorkflowBodyDto.safeParse(input); + expect(result.success).toBe(true); + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'no destinationProjectId', + input: { shareCredentials: [] }, + expectedErrorPath: ['destinationProjectId'], + }, + { + name: 'destinationProjectId not being a string', + input: { destinationProjectId: 1234 }, + expectedErrorPath: ['destinationProjectId'], + }, + { + name: 'shareCredentials not being an array', + input: { destinationProjectId: '1234', shareCredentials: '1235' }, + expectedErrorPath: ['shareCredentials'], + }, + { + name: 'shareCredentials not containing strings', + input: { destinationProjectId: '1234', shareCredentials: [1235] }, + expectedErrorPath: ['shareCredentials', 0], + }, + ])('should fail validation for $name', ({ input, expectedErrorPath }) => { + const result = TransferWorkflowBodyDto.safeParse(input); + + expect(result.success).toBe(false); + + if (expectedErrorPath) { + expect(result.error?.issues[0].path).toEqual(expectedErrorPath); + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/workflows/transfer.dto.ts b/packages/@n8n/api-types/src/dto/workflows/transfer.dto.ts new file mode 100644 index 0000000000..27afab9de8 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/transfer.dto.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +export class TransferWorkflowBodyDto extends Z.class({ + destinationProjectId: z.string(), + shareCredentials: z.array(z.string()).optional(), +}) {} diff --git a/packages/cli/src/credentials/credentials.controller.ts b/packages/cli/src/credentials/credentials.controller.ts index db30cb3f23..cddabf6ad2 100644 --- a/packages/cli/src/credentials/credentials.controller.ts +++ b/packages/cli/src/credentials/credentials.controller.ts @@ -330,7 +330,12 @@ export class CredentialsController { credentialsId: credentialId, projectId: In(toUnshare), }); - await this.enterpriseCredentialsService.shareWithProjects(req.user, credential, toShare, trx); + await this.enterpriseCredentialsService.shareWithProjects( + req.user, + credential.id, + toShare, + trx, + ); if (deleteResult.affected) { amountRemoved = deleteResult.affected; diff --git a/packages/cli/src/credentials/credentials.service.ee.ts b/packages/cli/src/credentials/credentials.service.ee.ts index 5f94312e94..1fc6eabc15 100644 --- a/packages/cli/src/credentials/credentials.service.ee.ts +++ b/packages/cli/src/credentials/credentials.service.ee.ts @@ -28,13 +28,13 @@ export class EnterpriseCredentialsService { async shareWithProjects( user: User, - credential: CredentialsEntity, + credentialId: string, shareWithIds: string[], entityManager?: EntityManager, ) { const em = entityManager ?? this.sharedCredentialsRepository.manager; - const projects = await em.find(Project, { + let projects = await em.find(Project, { where: [ { id: In(shareWithIds), @@ -55,11 +55,19 @@ export class EnterpriseCredentialsService { type: 'personal', }, ], + relations: { sharedCredentials: true }, }); + // filter out all projects that already own the credential + projects = projects.filter( + (p) => + !p.sharedCredentials.some( + (psc) => psc.credentialsId === credentialId && psc.role === 'credential:owner', + ), + ); const newSharedCredentials = projects.map((project) => this.sharedCredentialsRepository.create({ - credentialsId: credential.id, + credentialsId: credentialId, role: 'credential:user', projectId: project.id, }), diff --git a/packages/cli/src/databases/repositories/shared-credentials.repository.ts b/packages/cli/src/databases/repositories/shared-credentials.repository.ts index f696a3bd59..f52eeac6cd 100644 --- a/packages/cli/src/databases/repositories/shared-credentials.repository.ts +++ b/packages/cli/src/databases/repositories/shared-credentials.repository.ts @@ -56,6 +56,39 @@ export class SharedCredentialsRepository extends Repository { return sharedCredential.credentials; } + /** Get all credentials shared to a user */ + async findAllCredentialsForUser(user: User, scopes: Scope[], trx?: EntityManager) { + trx = trx ?? this.manager; + + let where: FindOptionsWhere = {}; + + if (!user.hasGlobalScope(scopes, { mode: 'allOf' })) { + const projectRoles = this.roleService.rolesWithScope('project', scopes); + const credentialRoles = this.roleService.rolesWithScope('credential', scopes); + where = { + role: In(credentialRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedCredential = await trx.find(SharedCredentials, { + where, + // TODO: write a small relations merger and use that one here + relations: { + credentials: { + shared: { project: { projectRelations: { user: true } } }, + }, + }, + }); + + return sharedCredential.map((sc) => ({ ...sc.credentials, projectId: sc.projectId })); + } + async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) { return await this.find({ relations: { credentials: true, project: { projectRelations: { user: true } } }, @@ -97,7 +130,10 @@ export class SharedCredentialsRepository extends Repository { options: | { scopes: Scope[] } | { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] }, + trx?: EntityManager, ) { + trx = trx ?? this.manager; + const projectRoles = 'scopes' in options ? this.roleService.rolesWithScope('project', options.scopes) @@ -107,7 +143,7 @@ export class SharedCredentialsRepository extends Repository { ? this.roleService.rolesWithScope('credential', options.scopes) : options.credentialRoles; - const sharings = await this.find({ + const sharings = await trx.find(SharedCredentials, { where: { role: In(credentialRoles), project: { @@ -118,6 +154,7 @@ export class SharedCredentialsRepository extends Repository { }, }, }); + return sharings.map((s) => s.credentialsId); } diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index 300b8018d8..5c2e7cab48 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -58,10 +58,4 @@ export declare namespace WorkflowRequest { type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>; type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>; - - type Transfer = AuthenticatedRequest< - { workflowId: string }, - {}, - { destinationProjectId: string } - >; } diff --git a/packages/cli/src/workflows/workflow.service.ee.ts b/packages/cli/src/workflows/workflow.service.ee.ts index 2d97e4f66e..4ff8bbc469 100644 --- a/packages/cli/src/workflows/workflow.service.ee.ts +++ b/packages/cli/src/workflows/workflow.service.ee.ts @@ -8,11 +8,13 @@ import { ApplicationError, NodeOperationError, WorkflowActivationError } from 'n import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { CredentialsService } from '@/credentials/credentials.service'; +import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; import { Project } from '@/databases/entities/project'; import { SharedWorkflow } from '@/databases/entities/shared-workflow'; import type { User } from '@/databases/entities/user'; import { CredentialsRepository } from '@/databases/repositories/credentials.repository'; +import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; @@ -37,6 +39,8 @@ export class EnterpriseWorkflowService { private readonly ownershipService: OwnershipService, private readonly projectService: ProjectService, private readonly activeWorkflowManager: ActiveWorkflowManager, + private readonly sharedCredentialsRepository: SharedCredentialsRepository, + private readonly enterpriseCredentialsService: EnterpriseCredentialsService, ) {} async shareWithProjects( @@ -46,9 +50,17 @@ export class EnterpriseWorkflowService { ) { const em = entityManager ?? this.sharedWorkflowRepository.manager; - const projects = await em.find(Project, { + let projects = await em.find(Project, { where: { id: In(shareWithIds), type: 'personal' }, + relations: { sharedWorkflows: true }, }); + // filter out all projects that already own the workflow + projects = projects.filter( + (p) => + !p.sharedWorkflows.some( + (swf) => swf.workflowId === workflowId && swf.role === 'workflow:owner', + ), + ); const newSharedWorkflows = projects // We filter by role === 'project:personalOwner' above and there should @@ -248,7 +260,12 @@ export class EnterpriseWorkflowService { }); } - async transferOne(user: User, workflowId: string, destinationProjectId: string) { + async transferOne( + user: User, + workflowId: string, + destinationProjectId: string, + shareCredentials: string[] = [], + ) { // 1. get workflow const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [ 'workflow:move', @@ -307,7 +324,28 @@ export class EnterpriseWorkflowService { ); }); - // 8. try to activate it again if it was active + // 8. share credentials into the destination project + await this.workflowRepository.manager.transaction(async (trx) => { + const allCredentials = await this.sharedCredentialsRepository.findAllCredentialsForUser( + user, + ['credential:share'], + trx, + ); + const credentialsAllowedToShare = allCredentials.filter((c) => + shareCredentials.includes(c.id), + ); + + for (const credential of credentialsAllowedToShare) { + await this.enterpriseCredentialsService.shareWithProjects( + user, + credential.id, + [destinationProject.id], + trx, + ); + } + }); + + // 9. try to activate it again if it was active if (wasActive) { try { await this.activeWorkflowManager.add(workflowId, 'update'); diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 07a170cdf8..e89bbd3822 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -1,4 +1,8 @@ -import { ImportWorkflowFromUrlDto, ManualRunQueryDto } from '@n8n/api-types'; +import { + ImportWorkflowFromUrlDto, + ManualRunQueryDto, + TransferWorkflowBodyDto, +} from '@n8n/api-types'; import { GlobalConfig } from '@n8n/config'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In, type FindOptionsRelations } from '@n8n/typeorm'; @@ -7,7 +11,6 @@ import express from 'express'; import { Logger } from 'n8n-core'; import { ApplicationError } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; -import { z } from 'zod'; import type { Project } from '@/databases/entities/project'; import { SharedWorkflow } from '@/databases/entities/shared-workflow'; @@ -18,7 +21,19 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { TagRepository } from '@/databases/repositories/tag.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import * as Db from '@/db'; -import { Delete, Get, Patch, Post, ProjectScope, Put, Query, RestController } from '@/decorators'; +import { + Body, + Delete, + Get, + Licensed, + Param, + Patch, + Post, + ProjectScope, + Put, + Query, + RestController, +} from '@/decorators'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; @@ -402,11 +417,10 @@ export class WorkflowsController { ); } + @Licensed('feat:sharing') @Put('/:workflowId/share') @ProjectScope('workflow:share') async share(req: WorkflowRequest.Share) { - if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found'); - const { workflowId } = req.params; const { shareWithIds } = req.body; @@ -472,13 +486,17 @@ export class WorkflowsController { @Put('/:workflowId/transfer') @ProjectScope('workflow:move') - async transfer(req: WorkflowRequest.Transfer) { - const body = z.object({ destinationProjectId: z.string() }).parse(req.body); - + async transfer( + req: AuthenticatedRequest, + _res: unknown, + @Param('workflowId') workflowId: string, + @Body body: TransferWorkflowBodyDto, + ) { return await this.enterpriseWorkflowService.transferOne( req.user, - req.params.workflowId, + workflowId, body.destinationProjectId, + body.shareCredentials, ); } } diff --git a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts index f6a5c1cfd5..e8a47cc055 100644 --- a/packages/cli/test/integration/credentials/credentials.api.ee.test.ts +++ b/packages/cli/test/integration/credentials/credentials.api.ee.test.ts @@ -951,6 +951,57 @@ describe('PUT /credentials/:id/share', () => { expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1); }); + test('should ignore sharing with owner project', async () => { + // ARRANGE + const project = await projectService.createTeamProject(owner, { name: 'Team Project' }); + const credential = await saveCredential(randomCredentialPayload(), { project }); + + // ACT + const response = await authOwnerAgent + .put(`/credentials/${credential.id}/share`) + .send({ shareWithIds: [project.id] }); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: credential.id }, + }); + + // ASSERT + expect(response.statusCode).toBe(200); + + expect(sharedCredentials).toHaveLength(1); + expect(sharedCredentials[0].projectId).toBe(project.id); + expect(sharedCredentials[0].role).toBe('credential:owner'); + }); + + test('should ignore sharing with project that already has it shared', async () => { + // ARRANGE + const project = await projectService.createTeamProject(owner, { name: 'Team Project' }); + const credential = await saveCredential(randomCredentialPayload(), { project }); + + const project2 = await projectService.createTeamProject(owner, { name: 'Team Project 2' }); + await shareCredentialWithProjects(credential, [project2]); + + // ACT + const response = await authOwnerAgent + .put(`/credentials/${credential.id}/share`) + .send({ shareWithIds: [project2.id] }); + + const sharedCredentials = await Container.get(SharedCredentialsRepository).find({ + where: { credentialsId: credential.id }, + }); + + // ASSERT + expect(response.statusCode).toBe(200); + + expect(sharedCredentials).toHaveLength(2); + expect(sharedCredentials).toEqual( + expect.arrayContaining([ + expect.objectContaining({ projectId: project.id, role: 'credential:owner' }), + expect.objectContaining({ projectId: project2.id, role: 'credential:user' }), + ]), + ); + }); + test('should respond 400 if invalid payload is provided', async () => { const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); diff --git a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts index 0bb272d0c6..421f27e419 100644 --- a/packages/cli/test/integration/workflows/workflow.service.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.ee.test.ts @@ -7,8 +7,8 @@ import { SharedWorkflowRepository } from '@/databases/repositories/shared-workfl import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import { Telemetry } from '@/telemetry'; import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee'; +import { mockInstance } from '@test/mocking'; -import { mockInstance } from '../../shared/mocking'; import * as testDb from '../shared/test-db'; import { FIRST_CREDENTIAL_ID, @@ -33,6 +33,8 @@ describe('EnterpriseWorkflowService', () => { mock(), mock(), mock(), + mock(), + mock(), ); }); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index b95304a583..7b5f6e1efa 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -8,18 +8,28 @@ import config from '@/config'; import type { Project } from '@/databases/entities/project'; import type { User } from '@/databases/entities/user'; import { ProjectRepository } from '@/databases/repositories/project.repository'; +import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; -import { License } from '@/license'; import { UserManagementMailer } from '@/user-management/email'; import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types'; +import { mockInstance } from '@test/mocking'; -import { mockInstance } from '../../shared/mocking'; -import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials'; -import { createTeamProject, linkUserToProject } from '../shared/db/projects'; +import { + affixRoleToSaveCredential, + getCredentialSharings, + shareCredentialWithProjects, + shareCredentialWithUsers, +} from '../shared/db/credentials'; +import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects'; import { createTag } from '../shared/db/tags'; import { createAdmin, createOwner, createUser, createUserShell } from '../shared/db/users'; -import { createWorkflow, getWorkflowSharing, shareWorkflowWithUsers } from '../shared/db/workflows'; +import { + createWorkflow, + getWorkflowSharing, + shareWorkflowWithProjects, + shareWorkflowWithUsers, +} from '../shared/db/workflows'; import { randomCredentialPayload } from '../shared/random'; import * as testDb from '../shared/test-db'; import type { SaveCredentialFunction } from '../shared/types'; @@ -44,7 +54,6 @@ let workflowRepository: WorkflowRepository; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); -const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true); const testServer = utils.setupTestServer({ endpointGroups: ['workflows'], enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'], @@ -95,15 +104,15 @@ describe('router should switch based on flag', () => { }); test('when sharing is disabled', async () => { - sharingSpy.mockReturnValueOnce(false); - + license.disable('feat:sharing'); await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) .send({ shareWithIds: [memberPersonalProject.id] }) - .expect(404); + .expect(403); }); test('when sharing is enabled', async () => { + license.enable('feat:sharing'); await authOwnerAgent .put(`/workflows/${savedWorkflowId}/share`) .send({ shareWithIds: [memberPersonalProject.id] }) @@ -290,6 +299,52 @@ describe('PUT /workflows/:workflowId/share', () => { config.set('userManagement.emails.mode', 'smtp'); }); + + test('should ignore sharing with owner project', async () => { + // ARRANGE + const project = ownerPersonalProject; + const workflow = await createWorkflow({ name: 'My workflow' }, project); + + await authOwnerAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [project.id] }) + .expect(200); + + const sharedWorkflows = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(sharedWorkflows).toHaveLength(1); + expect(sharedWorkflows).toEqual([ + expect.objectContaining({ projectId: project.id, role: 'workflow:owner' }), + ]); + }); + + test('should ignore sharing with project that already has it shared', async () => { + // ARRANGE + const project = ownerPersonalProject; + const workflow = await createWorkflow({ name: 'My workflow' }, project); + + const project2 = memberPersonalProject; + await shareWorkflowWithProjects(workflow, [{ project: project2 }]); + + await authOwnerAgent + .put(`/workflows/${workflow.id}/share`) + .send({ shareWithIds: [project2.id] }) + .expect(200); + + const sharedWorkflows = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(sharedWorkflows).toHaveLength(2); + expect(sharedWorkflows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ projectId: project.id, role: 'workflow:owner' }), + expect.objectContaining({ projectId: project2.id, role: 'workflow:editor' }), + ]), + ); + }); }); describe('GET /workflows/new', () => { @@ -297,7 +352,7 @@ describe('GET /workflows/new', () => { test(`should return an auto-incremented name, even when sharing is ${ sharingEnabled ? 'enabled' : 'disabled' }`, async () => { - sharingSpy.mockReturnValueOnce(sharingEnabled); + license.enable('feat:sharing'); await createWorkflow({ name: 'My workflow' }, owner); await createWorkflow({ name: 'My workflow 7' }, owner); @@ -1602,6 +1657,338 @@ describe('PUT /:workflowId/transfer', () => { expect(workflowFromDB).toMatchObject({ active: false }); }); + test('owner transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => { + // ARRANGE + const sourceProject = await createTeamProject('source project', admin); + const destinationProject = await createTeamProject('destination project', member); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject }); + + // ACT + await testServer + .authAgentFor(owner) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: destinationProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('admin transfers workflow from project they are not part of, e.g. test global cred sharing scope', async () => { + // ARRANGE + const sourceProject = await createTeamProject('source project', owner); + const destinationProject = await createTeamProject('destination project', owner); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject }); + + // ACT + await testServer + .authAgentFor(admin) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: destinationProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('member transfers workflow from personal project to team project and wf contains a credential that they can use but not share', async () => { + // ARRANGE + const sourceProject = memberPersonalProject; + const destinationProject = await createTeamProject('destination project', member); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { user: owner }); + + await shareCredentialWithUsers(credential, [member]); + + // ACT + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: ownerPersonalProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('member transfers workflow from their personal project to another team project in which they have editor role', async () => { + // ARRANGE + const sourceProject = memberPersonalProject; + const destinationProject = await createTeamProject('destination project'); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject }); + + await linkUserToProject(member, destinationProject, 'project:editor'); + + // ACT + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: destinationProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('member transfers workflow from a team project as project admin to another team project in which they have editor role', async () => { + // ARRANGE + const sourceProject = await createTeamProject('source project', member); + const destinationProject = await createTeamProject('destination project'); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject }); + + await linkUserToProject(member, destinationProject, 'project:editor'); + + // ACT + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: destinationProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('member transfers workflow from a team project as project admin to another team project in which they have editor role but cannot share the credential that is only shared into the source project', async () => { + // ARRANGE + const sourceProject = await createTeamProject('source project', member); + const destinationProject = await createTeamProject('destination project'); + const ownerProject = await getPersonalProject(owner); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { user: owner }); + + await linkUserToProject(member, destinationProject, 'project:editor'); + await shareCredentialWithProjects(credential, [sourceProject]); + + // ACT + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: ownerProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + }); + + test('member transfers workflow from a team project as project admin to another team project in which they have editor role but cannot share all the credentials', async () => { + // ARRANGE + const sourceProject = await createTeamProject('source project', member); + const workflow = await createWorkflow({}, sourceProject); + const credential = await saveCredential(randomCredentialPayload(), { project: sourceProject }); + + const ownersCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const destinationProject = await createTeamProject('destination project'); + await linkUserToProject(member, destinationProject, 'project:editor'); + + // ACT + await testServer + .authAgentFor(member) + .put(`/workflows/${workflow.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + shareCredentials: [credential.id, ownersCredential.id], + }) + .expect(200); + + // ASSERT + const allWorkflowSharings = await getWorkflowSharing(workflow); + expect(allWorkflowSharings).toHaveLength(1); + expect(allWorkflowSharings[0]).toMatchObject({ + projectId: destinationProject.id, + workflowId: workflow.id, + role: 'workflow:owner', + }); + + const allCredentialSharings = await getCredentialSharings(credential); + expect(allCredentialSharings).toHaveLength(2); + expect(allCredentialSharings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + projectId: sourceProject.id, + credentialsId: credential.id, + role: 'credential:owner', + }), + expect.objectContaining({ + projectId: destinationProject.id, + credentialsId: credential.id, + role: 'credential:user', + }), + ]), + ); + + const ownerCredentialSharings = await getCredentialSharings(ownersCredential); + expect(ownerCredentialSharings).toHaveLength(1); + expect(ownerCredentialSharings).toEqual([ + expect.objectContaining({ + projectId: ownerPersonalProject.id, + credentialsId: ownersCredential.id, + role: 'credential:owner', + }), + ]); + }); + test('returns a 500 if the workflow cannot be activated due to an unknown error', async () => { // // ARRANGE diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index fd79910f4e..0556372197 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -38,9 +38,13 @@ let anotherMember: User; let authOwnerAgent: SuperAgentTest; let authMemberAgent: SuperAgentTest; -jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(false); - -const testServer = utils.setupTestServer({ endpointGroups: ['workflows'] }); +const testServer = utils.setupTestServer({ + endpointGroups: ['workflows'], + enabledFeatures: ['feat:sharing'], + quotas: { + 'quota:maxTeamProjects': -1, + }, +}); const license = testServer.license; const { objectContaining, arrayContaining, any } = expect; @@ -48,9 +52,19 @@ const { objectContaining, arrayContaining, any } = expect; const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager); let projectRepository: ProjectRepository; +let projectService: ProjectService; -beforeAll(async () => { +beforeEach(async () => { + await testDb.truncate([ + 'Workflow', + 'SharedWorkflow', + 'Tag', + 'WorkflowHistory', + 'Project', + 'ProjectRelation', + ]); projectRepository = Container.get(ProjectRepository); + projectService = Container.get(ProjectService); owner = await createOwner(); authOwnerAgent = testServer.authAgentFor(owner); member = await createMember(); @@ -58,9 +72,8 @@ beforeAll(async () => { anotherMember = await createMember(); }); -beforeEach(async () => { - jest.resetAllMocks(); - await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag', 'WorkflowHistory']); +afterEach(() => { + jest.clearAllMocks(); }); describe('POST /workflows', () => { @@ -271,7 +284,7 @@ describe('POST /workflows', () => { type: 'team', }), ); - await Container.get(ProjectService).addUser(project.id, owner.id, 'project:admin'); + await projectService.addUser(project.id, owner.id, 'project:admin'); // // ACT @@ -345,7 +358,7 @@ describe('POST /workflows', () => { type: 'team', }), ); - await Container.get(ProjectService).addUser(project.id, member.id, 'project:viewer'); + await projectService.addUser(project.id, member.id, 'project:viewer'); // // ACT diff --git a/packages/design-system/src/css/notification.scss b/packages/design-system/src/css/notification.scss index 80f698d3e2..6a7b9f7a6e 100644 --- a/packages/design-system/src/css/notification.scss +++ b/packages/design-system/src/css/notification.scss @@ -43,6 +43,10 @@ font-size: var.$notification-title-font-size; color: var.$notification-title-color; margin: 0; + + &:first-letter { + text-transform: uppercase; + } } @include mixins.e(content) { diff --git a/packages/editor-ui/src/api/workflows.ee.ts b/packages/editor-ui/src/api/workflows.ee.ts index 993d0d8725..7ebfe38aaa 100644 --- a/packages/editor-ui/src/api/workflows.ee.ts +++ b/packages/editor-ui/src/api/workflows.ee.ts @@ -1,3 +1,4 @@ +import type { TransferWorkflowBodyDto } from '@n8n/api-types'; import type { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; import type { IDataObject } from 'n8n-workflow'; @@ -18,9 +19,7 @@ export async function setWorkflowSharedWith( export async function moveWorkflowToProject( context: IRestApiContext, id: string, - destinationProjectId: string, + body: TransferWorkflowBodyDto, ): Promise { - return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, { - destinationProjectId, - }); + return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body); } diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts index 5da1d7d050..701366f370 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.test.ts @@ -1,6 +1,7 @@ import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import { createComponentRenderer } from '@/__tests__/render'; +import { createTestWorkflow } from '@/__tests__/mocks'; import { createProjectListItem } from '@/__tests__/data/projects'; import { getDropdownItems, mockedStore } from '@/__tests__/utils'; import type { MockedStore } from '@/__tests__/utils'; @@ -8,6 +9,8 @@ import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants'; import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue'; import { useTelemetry } from '@/composables/useTelemetry'; import { useProjectsStore } from '@/stores/projects.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useCredentialsStore } from '@/stores/credentials.store'; const renderComponent = createComponentRenderer(ProjectMoveResourceModal, { pinia: createTestingPinia(), @@ -23,26 +26,32 @@ const renderComponent = createComponentRenderer(ProjectMoveResourceModal, { let telemetry: ReturnType; let projectsStore: MockedStore; +let workflowsStore: MockedStore; +let credentialsStore: MockedStore; describe('ProjectMoveResourceModal', () => { beforeEach(() => { vi.clearAllMocks(); telemetry = useTelemetry(); projectsStore = mockedStore(useProjectsStore); + workflowsStore = mockedStore(useWorkflowsStore); + credentialsStore = mockedStore(useCredentialsStore); }); it('should send telemetry when mounted', async () => { const telemetryTrackSpy = vi.spyOn(telemetry, 'track'); projectsStore.availableProjects = [createProjectListItem()]; + workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow()); const props = { modalName: PROJECT_MOVE_RESOURCE_MODAL, data: { resourceType: 'workflow', - resourceTypeLabel: 'Workflow', + resourceTypeLabel: 'workflow', resource: { id: '1', + name: 'My Workflow', homeProject: { id: '2', name: 'My Project', @@ -59,14 +68,16 @@ describe('ProjectMoveResourceModal', () => { it('should show no available projects message', async () => { projectsStore.availableProjects = []; + workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow()); const props = { modalName: PROJECT_MOVE_RESOURCE_MODAL, data: { resourceType: 'workflow', - resourceTypeLabel: 'Workflow', + resourceTypeLabel: 'workflow', resource: { id: '1', + name: 'My Workflow', homeProject: { id: '2', name: 'My Project', @@ -89,6 +100,7 @@ describe('ProjectMoveResourceModal', () => { resourceTypeLabel: 'Workflow', resource: { id: '1', + name: 'My Workflow', homeProject: { id: projects[0].id, name: projects[0].name, @@ -112,4 +124,118 @@ describe('ProjectMoveResourceModal', () => { expect(projectSelect).toBeVisible(); }); + + it('should not load workflow if the resource is a credential', async () => { + const telemetryTrackSpy = vi.spyOn(telemetry, 'track'); + projectsStore.availableProjects = [createProjectListItem()]; + + const props = { + modalName: PROJECT_MOVE_RESOURCE_MODAL, + data: { + resourceType: 'credential', + resourceTypeLabel: 'credential', + resource: { + id: '1', + name: 'My credential', + homeProject: { + id: '2', + name: 'My Project', + }, + }, + }, + }; + + const { getByText } = renderComponent({ props }); + expect(telemetryTrackSpy).toHaveBeenCalledWith( + 'User clicked to move a credential', + expect.objectContaining({ credential_id: '1' }), + ); + expect(workflowsStore.fetchWorkflow).not.toHaveBeenCalled(); + expect(getByText(/Moving will remove any existing sharing for this credential/)).toBeVisible(); + }); + + it('should send credential IDs when workflow moved with used credentials and checkbox checked', async () => { + const destinationProject = createProjectListItem(); + const currentProjectId = '123'; + const movedWorkflow = { + ...createTestWorkflow(), + usedCredentials: [ + { + id: '1', + name: 'PG Credential', + credentialType: 'postgres', + currentUserHasAccess: true, + }, + { + id: '2', + name: 'Notion Credential', + credentialType: 'notion', + currentUserHasAccess: true, + }, + ], + }; + + projectsStore.currentProjectId = currentProjectId; + projectsStore.availableProjects = [destinationProject]; + workflowsStore.fetchWorkflow.mockResolvedValueOnce(movedWorkflow); + credentialsStore.fetchAllCredentials.mockResolvedValueOnce([ + { + id: '1', + name: 'PG Credential', + createdAt: '2021-01-01T00:00:00Z', + updatedAt: '2021-01-01T00:00:00Z', + type: 'postgres', + scopes: ['credential:share'], + isManaged: false, + }, + { + id: '2', + name: 'Notion Credential', + createdAt: '2021-01-01T00:00:00Z', + updatedAt: '2021-01-01T00:00:00Z', + type: 'notion', + scopes: ['credential:share'], + isManaged: false, + }, + { + id: '3', + name: 'Another Credential', + createdAt: '2021-01-01T00:00:00Z', + updatedAt: '2021-01-01T00:00:00Z', + type: 'another', + scopes: ['credential:share'], + isManaged: false, + }, + ]); + + const props = { + modalName: PROJECT_MOVE_RESOURCE_MODAL, + data: { + resourceType: 'workflow', + resourceTypeLabel: 'workflow', + resource: movedWorkflow, + }, + }; + const { getByTestId, getByText } = renderComponent({ props }); + expect(getByTestId('project-move-resource-modal-button')).toBeDisabled(); + expect(getByText(/Moving will remove any existing sharing for this workflow/)).toBeVisible(); + + const projectSelect = getByTestId('project-move-resource-modal-select'); + expect(projectSelect).toBeVisible(); + + const projectSelectDropdownItems = await getDropdownItems(projectSelect); + await userEvent.click(projectSelectDropdownItems[0]); + + expect(getByTestId('project-move-resource-modal-button')).toBeEnabled(); + + await userEvent.click(getByTestId('project-move-resource-modal-checkbox-all')); + await userEvent.click(getByTestId('project-move-resource-modal-button')); + + expect(projectsStore.moveResourceToProject).toHaveBeenCalledWith( + 'workflow', + movedWorkflow.id, + destinationProject.id, + ['1', '2'], + ); + }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue index cab0bd7a99..a06adbe6c8 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue @@ -1,6 +1,7 @@ @@ -195,8 +245,10 @@ onMounted(() => { > - -
+ {{ i18n.baseText('projects.move.resource.modal.message.sharingInfo', { adjustToNumber: props.data.resource.sharedWithProjects?.length, @@ -206,6 +258,50 @@ onMounted(() => { }) }} + + + + + + + + + + {{ @@ -219,7 +315,12 @@ onMounted(() => { {{ i18n.baseText('generic.cancel') }} - + {{ i18n.baseText('projects.move.resource.modal.button', { interpolate: { resourceTypeLabel: props.data.resourceTypeLabel }, @@ -236,4 +337,13 @@ onMounted(() => { display: flex; justify-content: flex-end; } + +.textBlock { + display: block; + margin-top: var(--spacing-s); +} + +.tooltipText { + text-decoration: underline; +} diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue new file mode 100644 index 0000000000..661c46f845 --- /dev/null +++ b/packages/editor-ui/src/components/Projects/ProjectMoveResourceModalCredentialsList.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts index 1900173093..d60a68cae7 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts +++ b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.test.ts @@ -18,43 +18,59 @@ describe('ProjectMoveSuccessToastMessage', () => { it('should show credentials message if the resource is a workflow', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'My Workflow', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Workflow, - resourceTypeLabel: 'Workflow', targetProject: { id: '2', name: 'My Project', }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; const { getByText } = renderComponent({ props }); - expect(getByText(/Please double check any credentials/)).toBeInTheDocument(); + expect(getByText(/The workflow's credentials were not shared/)).toBeInTheDocument(); + }); + + it('should show all credentials shared message if the resource is a workflow', async () => { + const props = { + routeName: VIEWS.PROJECTS_WORKFLOWS, + resourceType: ResourceType.Workflow, + targetProject: { + id: '2', + name: 'My Project', + }, + isShareCredentialsChecked: true, + areAllUsedCredentialsShareable: true, + }; + const { getByText } = renderComponent({ props }); + expect(getByText(/The workflow's credentials were shared/)).toBeInTheDocument(); + }); + + it('should show not all credentials shared message if the resource is a workflow', async () => { + const props = { + routeName: VIEWS.PROJECTS_WORKFLOWS, + resourceType: ResourceType.Workflow, + targetProject: { + id: '2', + name: 'My Project', + }, + isShareCredentialsChecked: true, + areAllUsedCredentialsShareable: false, + }; + const { getByText } = renderComponent({ props }); + expect(getByText(/Due to missing permissions/)).toBeInTheDocument(); }); it('should show link if the target project type is team project', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'My Workflow', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Workflow, - resourceTypeLabel: 'workflow', targetProject: { id: '2', name: 'Team Project', type: ProjectTypes.Team, }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; const { getByRole } = renderComponent({ props }); expect(getByRole('link')).toBeInTheDocument(); @@ -63,25 +79,17 @@ describe('ProjectMoveSuccessToastMessage', () => { it('should show only general if the resource is credential and moved to a personal project', async () => { const props = { routeName: VIEWS.PROJECTS_WORKFLOWS, - resource: { - id: '1', - name: 'Notion API', - homeProject: { - id: '2', - name: 'My Project', - }, - }, resourceType: ResourceType.Credential, - resourceTypeLabel: 'credential', targetProject: { id: '2', name: 'Personal Project', type: ProjectTypes.Personal, }, + isShareCredentialsChecked: false, + areAllUsedCredentialsShareable: false, }; - const { getByText, queryByText, queryByRole } = renderComponent({ props }); - expect(getByText(/credential was moved to /)).toBeInTheDocument(); - expect(queryByText(/Please double check any credentials/)).not.toBeInTheDocument(); + const { queryByText, queryByRole } = renderComponent({ props }); + expect(queryByText(/The workflow's credentials were not shared/)).not.toBeInTheDocument(); expect(queryByRole('link')).not.toBeInTheDocument(); }); }); diff --git a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue index 8bf8fe2af4..e8f3c45d33 100644 --- a/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue +++ b/packages/editor-ui/src/components/Projects/ProjectMoveSuccessToastMessage.vue @@ -1,57 +1,52 @@ diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 12301506a3..6c0e857619 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -2644,13 +2644,18 @@ "projects.move.resource.modal.message.note": "Note", "projects.move.resource.modal.message.sharingNote": "{note}: Moving will remove any existing sharing for this {resourceTypeLabel}.", "projects.move.resource.modal.message.sharingInfo": "(Currently shared with {numberOfProjects} project) | (Currently shared with {numberOfProjects} projects)", + "projects.move.resource.modal.message.usedCredentials": "Also share the {usedCredentials} used by this workflow to ensure it will continue to run correctly", + "projects.move.resource.modal.message.usedCredentials.number": "{number} credential | {number} credentials", + "projects.move.resource.modal.message.unAccessibleCredentials": "Some credentials", + "projects.move.resource.modal.message.unAccessibleCredentials.note": "{credentials} used in this workflow will not be shared", "projects.move.resource.modal.message.noProjects": "Currently there are not any projects or users available for you to move this {resourceTypeLabel} to.", "projects.move.resource.modal.button": "Move {resourceTypeLabel}", "projects.move.resource.modal.selectPlaceholder": "Select project or user...", "projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}", - "projects.move.resource.success.title": "Successfully moved {resourceTypeLabel}", - "projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName}. {workflow} {link}", - "projects.move.resource.success.message.workflow": "Please double check any credentials this workflow is using are also shared with {targetProjectName}.", + "projects.move.resource.success.title": "{resourceTypeLabel} '{resourceName}' is moved to '{targetProjectName}'", + "projects.move.resource.success.message.workflow": "The workflow's credentials were not shared with the project.", + "projects.move.resource.success.message.workflow.withAllCredentials": "The workflow's credentials were shared with the project.", + "projects.move.resource.success.message.workflow.withSomeCredentials": "Due to missing permissions not all the workflow's credentials were shared with the project.", "projects.move.resource.success.link": "View {targetProjectName}", "projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with {count} users", "projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with {count} users", diff --git a/packages/editor-ui/src/stores/projects.store.ts b/packages/editor-ui/src/stores/projects.store.ts index 8c652eb018..0183236517 100644 --- a/packages/editor-ui/src/stores/projects.store.ts +++ b/packages/editor-ui/src/stores/projects.store.ts @@ -161,9 +161,13 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => { resourceType: 'workflow' | 'credential', resourceId: string, projectId: string, + shareCredentials?: string[], ) => { if (resourceType === 'workflow') { - await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, projectId); + await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, { + destinationProjectId: projectId, + shareCredentials, + }); await workflowsStore.fetchAllWorkflows(currentProjectId.value); } else { await credentialsEEApi.moveCredentialToProject(