fix(editor): Optionally share credentials used by the workflow when moving the workflow between projects (#12524)
Some checks failed
Test Master / install-and-build (push) Has been cancelled
Test Master / Unit tests (18.x) (push) Has been cancelled
Test Master / Unit tests (20.x) (push) Has been cancelled
Test Master / Unit tests (22.4) (push) Has been cancelled
Test Master / Lint (push) Has been cancelled
Test Master / Notify Slack on failure (push) Has been cancelled

Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
Csaba Tuncsik 2025-02-21 11:05:37 +01:00 committed by GitHub
parent 29ae2396c9
commit 7bd83d7d33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1078 additions and 136 deletions

View file

@ -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';

View file

@ -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);
}
});
});
});

View file

@ -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(),
}) {}

View file

@ -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;

View file

@ -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,
}),

View file

@ -56,6 +56,39 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
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<SharedCredentials> = {};
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<SharedCredentials> {
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<SharedCredentials> {
? 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<SharedCredentials> {
},
},
});
return sharings.map((s) => s.credentialsId);
}

View file

@ -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 }
>;
}

View file

@ -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');

View file

@ -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,
);
}
}

View file

@ -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 });

View file

@ -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(),
);
});

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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<void> {
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, {
destinationProjectId,
});
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body);
}

View file

@ -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<typeof useTelemetry>;
let projectsStore: MockedStore<typeof useProjectsStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let credentialsStore: MockedStore<typeof useCredentialsStore>;
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'],
);
});
});

View file

@ -1,6 +1,7 @@
<script lang="ts" setup>
import { ref, computed, onMounted, h } from 'vue';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { truncate } from 'n8n-design-system';
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
@ -10,10 +11,13 @@ import { ResourceType, splitName } from '@/utils/projects.utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { ProjectTypes } from '@/types/projects.types';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
import { useToast } from '@/composables/useToast';
import { getResourcePermissions } from '@/permissions';
import { sortByProperty } from '@/utils/sortUtils';
import type { EventBus } from 'n8n-design-system/utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
const props = defineProps<{
modalName: string;
@ -29,11 +33,40 @@ const i18n = useI18n();
const uiStore = useUIStore();
const toast = useToast();
const projectsStore = useProjectsStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const telemetry = useTelemetry();
const filter = ref('');
const projectId = ref<string | null>(null);
const processedName = computed(
const shareUsedCredentials = ref(false);
const usedCredentials = ref<IUsedCredential[]>([]);
const allCredentials = ref<ICredentialsResponse[]>([]);
const shareableCredentials = computed(() =>
allCredentials.value.filter(
(credential) =>
getResourcePermissions(credential.scopes).credential.share &&
usedCredentials.value.find((uc) => uc.id === credential.id),
),
);
const unShareableCredentials = computed(() =>
usedCredentials.value.reduce(
(acc, uc) => {
const credential = credentialsStore.getCredentialById(uc.id);
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
if (!credentialPermissions.share) {
if (credentialPermissions.read) {
acc.push(credential);
} else {
acc.push(uc);
}
}
return acc;
},
[] as Array<IUsedCredential | ICredentialsResponse>,
),
);
const homeProjectName = computed(
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
);
const availableProjects = computed(() =>
@ -53,6 +86,12 @@ const selectedProject = computed(() =>
availableProjects.value.find((p) => p.id === projectId.value),
);
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
const targetProjectName = computed(() => {
const { name, email } = splitName(selectedProject.value?.name ?? '');
return truncate(name ?? email ?? '', 25);
});
const resourceName = computed(() => truncate(props.data.resource.name, 25));
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
resource.homeProject?.type === ProjectTypes.Team;
@ -81,6 +120,7 @@ const moveResource = async () => {
props.data.resourceType,
props.data.resource.id,
selectedProject.value.id,
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
);
closeModal();
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
@ -91,17 +131,17 @@ const moveResource = async () => {
title: i18n.baseText('projects.move.resource.success.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: resourceName.value,
targetProjectName: targetProjectName.value,
},
}),
message: h(ProjectMoveSuccessToastMessage, {
routeName:
props.data.resourceType === ResourceType.Workflow
? VIEWS.PROJECTS_WORKFLOWS
: VIEWS.PROJECTS_CREDENTIALS,
resource: props.data.resource,
routeName: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS,
resourceType: props.data.resourceType,
resourceTypeLabel: props.data.resourceTypeLabel,
targetProject: selectedProject.value,
isShareCredentialsChecked: shareUsedCredentials.value,
areAllUsedCredentialsShareable:
shareableCredentials.value.length === usedCredentials.value.length,
}),
type: 'success',
duration: 8000,
@ -119,18 +159,28 @@ const moveResource = async () => {
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: props.data.resource.name,
resourceName: resourceName.value,
},
}),
);
}
};
onMounted(() => {
onMounted(async () => {
telemetry.track(`User clicked to move a ${props.data.resourceType}`, {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
if (isResourceWorkflow.value) {
const [workflow, credentials] = await Promise.all([
workflowsStore.fetchWorkflow(props.data.resource.id),
credentialsStore.fetchAllCredentials(),
]);
usedCredentials.value = workflow?.usedCredentials ?? [];
allCredentials.value = credentials ?? [];
}
});
</script>
<template>
@ -146,19 +196,19 @@ onMounted(() => {
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message">
<template #resourceName
><strong>{{ props.data.resource.name }}</strong></template
><strong>{{ resourceName }}</strong></template
>
<template v-if="isResourceInTeamProject" #inTeamProject>
<i18n-t keypath="projects.move.resource.modal.message.team">
<template #resourceHomeProjectName
><strong>{{ processedName }}</strong></template
><strong>{{ homeProjectName }}</strong></template
>
</i18n-t>
</template>
<template v-else #inPersonalProject>
<i18n-t keypath="projects.move.resource.modal.message.personal">
<template #resourceHomeProjectName
><strong>{{ processedName }}</strong></template
><strong>{{ homeProjectName }}</strong></template
>
</i18n-t>
</template>
@ -195,8 +245,10 @@ onMounted(() => {
>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
</i18n-t>
<span v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0">
<br />
<span
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
:class="$style.textBlock"
>
{{
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
adjustToNumber: props.data.resource.sharedWithProjects?.length,
@ -206,6 +258,50 @@ onMounted(() => {
})
}}</span
>
<N8nCheckbox
v-if="shareableCredentials.length"
v-model="shareUsedCredentials"
:class="$style.textBlock"
data-test-id="project-move-resource-modal-checkbox-all"
>
<i18n-t keypath="projects.move.resource.modal.message.usedCredentials">
<template #usedCredentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">
{{
i18n.baseText('projects.move.resource.modal.message.usedCredentials.number', {
adjustToNumber: shareableCredentials.length,
interpolate: { number: shareableCredentials.length },
})
}}
</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="shareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</N8nCheckbox>
<span v-if="unShareableCredentials.length" :class="$style.textBlock">
<i18n-t keypath="projects.move.resource.modal.message.unAccessibleCredentials.note">
<template #credentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">{{
i18n.baseText('projects.move.resource.modal.message.unAccessibleCredentials')
}}</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="unShareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</span>
</N8nText>
</div>
<N8nText v-else>{{
@ -219,7 +315,12 @@ onMounted(() => {
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton :disabled="!projectId" type="primary" @click="moveResource">
<N8nButton
:disabled="!projectId"
type="primary"
data-test-id="project-move-resource-modal-button"
@click="moveResource"
>
{{
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;
}
</style>

View file

@ -0,0 +1,74 @@
<script setup lang="ts">
import type { RouteLocationNamedRaw } from 'vue-router';
import type { ICredentialsResponse, IUsedCredential } from '@/Interface';
import { getResourcePermissions } from '@/permissions';
import { VIEWS } from '@/constants';
const props = withDefaults(
defineProps<{
credentials?: Array<ICredentialsResponse | IUsedCredential>;
currentProjectId?: string;
}>(),
{
credentials: () => [],
currentProjectId: '',
},
);
const isCredentialReadable = (credential: ICredentialsResponse | IUsedCredential) =>
'scopes' in credential ? getResourcePermissions(credential.scopes).credential.read : false;
const getCredentialRouterLocation = (
credential: ICredentialsResponse | IUsedCredential,
): RouteLocationNamedRaw => {
const isSharedWithCurrentProject = credential.sharedWithProjects?.find(
(p) => p.id === props.currentProjectId,
);
const params: {
projectId?: string;
credentialId: string;
} = { credentialId: credential.id };
if (isSharedWithCurrentProject ?? credential.homeProject?.id) {
params.projectId = isSharedWithCurrentProject
? props.currentProjectId
: credential.homeProject?.id;
}
return {
name: isSharedWithCurrentProject ? VIEWS.PROJECTS_CREDENTIALS : VIEWS.CREDENTIALS,
params,
};
};
</script>
<template>
<ul :class="$style.credentialsList">
<li v-for="credential in props.credentials" :key="credential.id">
<router-link
v-if="isCredentialReadable(credential)"
target="_blank"
:to="getCredentialRouterLocation(credential)"
>
{{ credential.name }}
</router-link>
<span v-else>{{ credential.name }}</span>
</li>
</ul>
</template>
<style module lang="scss">
.credentialsList {
list-style-type: none;
padding: 0;
margin: 0;
li {
padding: 0 0 var(--spacing-3xs);
&:last-child {
padding-bottom: 0;
}
}
}
</style>

View file

@ -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();
});
});

View file

@ -1,57 +1,52 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { truncate } from 'n8n-design-system';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { ResourceType, splitName } from '@/utils/projects.utils';
import type { ProjectListItem } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
routeName: string;
resource: IWorkflowDb | ICredentialsResponse;
resourceType: ResourceType;
resourceTypeLabel: string;
targetProject: ProjectListItem;
isShareCredentialsChecked: boolean;
areAllUsedCredentialsShareable: boolean;
}>();
const i18n = useI18n();
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
const projectName = computed(() => {
const targetProjectName = computed(() => {
const { name, email } = splitName(props.targetProject?.name ?? '');
return truncate(name ?? email ?? '', 25);
});
</script>
<template>
<i18n-t keypath="projects.move.resource.success.message">
<template #resourceTypeLabel>{{ props.resourceTypeLabel }}</template>
<template #resourceName
><strong>{{ props.resource.name }}</strong></template
>
<template #targetProjectName
><strong>{{ projectName }}</strong></template
>
<template v-if="isWorkflow" #workflow>
<N8nText tag="p" class="pt-xs">
<i18n-t keypath="projects.move.resource.success.message.workflow">
<template #targetProjectName
><strong>{{ projectName }}</strong></template
>
</i18n-t>
</N8nText>
</template>
<template v-if="isTargetProjectTeam" #link>
<p class="pt-s">
<router-link
:to="{
name: props.routeName,
params: { projectId: props.targetProject.id },
}"
>
<i18n-t keypath="projects.move.resource.success.link">
<template #targetProjectName>{{ projectName }}</template>
</i18n-t>
</router-link>
</p>
</template>
</i18n-t>
<div>
<N8nText v-if="isWorkflow" tag="p" class="pt-xs">
<span v-if="props.isShareCredentialsChecked && props.areAllUsedCredentialsShareable">{{
i18n.baseText('projects.move.resource.success.message.workflow.withAllCredentials')
}}</span>
<span v-else-if="props.isShareCredentialsChecked">{{
i18n.baseText('projects.move.resource.success.message.workflow.withSomeCredentials')
}}</span>
<span v-else>{{ i18n.baseText('projects.move.resource.success.message.workflow') }}</span>
</N8nText>
<p v-if="isTargetProjectTeam" class="pt-s">
<router-link
:to="{
name: props.routeName,
params: { projectId: props.targetProject.id },
}"
>
{{
i18n.baseText('projects.move.resource.success.link', {
interpolate: { targetProjectName },
})
}}
</router-link>
</p>
</div>
</template>

View file

@ -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",

View file

@ -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(