mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
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
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:
parent
29ae2396c9
commit
7bd83d7d33
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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(),
|
||||
}) {}
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
>;
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue