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 { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
|
||||||
export { ManualRunQueryDto } from './workflows/manual-run-query.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 { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto';
|
||||||
export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.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,
|
credentialsId: credentialId,
|
||||||
projectId: In(toUnshare),
|
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) {
|
if (deleteResult.affected) {
|
||||||
amountRemoved = deleteResult.affected;
|
amountRemoved = deleteResult.affected;
|
||||||
|
|
|
@ -28,13 +28,13 @@ export class EnterpriseCredentialsService {
|
||||||
|
|
||||||
async shareWithProjects(
|
async shareWithProjects(
|
||||||
user: User,
|
user: User,
|
||||||
credential: CredentialsEntity,
|
credentialId: string,
|
||||||
shareWithIds: string[],
|
shareWithIds: string[],
|
||||||
entityManager?: EntityManager,
|
entityManager?: EntityManager,
|
||||||
) {
|
) {
|
||||||
const em = entityManager ?? this.sharedCredentialsRepository.manager;
|
const em = entityManager ?? this.sharedCredentialsRepository.manager;
|
||||||
|
|
||||||
const projects = await em.find(Project, {
|
let projects = await em.find(Project, {
|
||||||
where: [
|
where: [
|
||||||
{
|
{
|
||||||
id: In(shareWithIds),
|
id: In(shareWithIds),
|
||||||
|
@ -55,11 +55,19 @@ export class EnterpriseCredentialsService {
|
||||||
type: 'personal',
|
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) =>
|
const newSharedCredentials = projects.map((project) =>
|
||||||
this.sharedCredentialsRepository.create({
|
this.sharedCredentialsRepository.create({
|
||||||
credentialsId: credential.id,
|
credentialsId: credentialId,
|
||||||
role: 'credential:user',
|
role: 'credential:user',
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -56,6 +56,39 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
return sharedCredential.credentials;
|
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) {
|
async findByCredentialIds(credentialIds: string[], role: CredentialSharingRole) {
|
||||||
return await this.find({
|
return await this.find({
|
||||||
relations: { credentials: true, project: { projectRelations: { user: true } } },
|
relations: { credentials: true, project: { projectRelations: { user: true } } },
|
||||||
|
@ -97,7 +130,10 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
options:
|
options:
|
||||||
| { scopes: Scope[] }
|
| { scopes: Scope[] }
|
||||||
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
|
| { projectRoles: ProjectRole[]; credentialRoles: CredentialSharingRole[] },
|
||||||
|
trx?: EntityManager,
|
||||||
) {
|
) {
|
||||||
|
trx = trx ?? this.manager;
|
||||||
|
|
||||||
const projectRoles =
|
const projectRoles =
|
||||||
'scopes' in options
|
'scopes' in options
|
||||||
? this.roleService.rolesWithScope('project', options.scopes)
|
? this.roleService.rolesWithScope('project', options.scopes)
|
||||||
|
@ -107,7 +143,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
? this.roleService.rolesWithScope('credential', options.scopes)
|
? this.roleService.rolesWithScope('credential', options.scopes)
|
||||||
: options.credentialRoles;
|
: options.credentialRoles;
|
||||||
|
|
||||||
const sharings = await this.find({
|
const sharings = await trx.find(SharedCredentials, {
|
||||||
where: {
|
where: {
|
||||||
role: In(credentialRoles),
|
role: In(credentialRoles),
|
||||||
project: {
|
project: {
|
||||||
|
@ -118,6 +154,7 @@ export class SharedCredentialsRepository extends Repository<SharedCredentials> {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return sharings.map((s) => s.credentialsId);
|
return sharings.map((s) => s.credentialsId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -58,10 +58,4 @@ export declare namespace WorkflowRequest {
|
||||||
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>;
|
type ManualRun = AuthenticatedRequest<{ workflowId: string }, {}, ManualRunPayload, {}>;
|
||||||
|
|
||||||
type Share = AuthenticatedRequest<{ workflowId: string }, {}, { shareWithIds: string[] }>;
|
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 { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||||
import { CredentialsService } from '@/credentials/credentials.service';
|
import { CredentialsService } from '@/credentials/credentials.service';
|
||||||
|
import { EnterpriseCredentialsService } from '@/credentials/credentials.service.ee';
|
||||||
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
import type { CredentialsEntity } from '@/databases/entities/credentials-entity';
|
||||||
import { Project } from '@/databases/entities/project';
|
import { Project } from '@/databases/entities/project';
|
||||||
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
import { CredentialsRepository } from '@/databases/repositories/credentials.repository';
|
||||||
|
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
|
||||||
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
@ -37,6 +39,8 @@ export class EnterpriseWorkflowService {
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
private readonly projectService: ProjectService,
|
private readonly projectService: ProjectService,
|
||||||
private readonly activeWorkflowManager: ActiveWorkflowManager,
|
private readonly activeWorkflowManager: ActiveWorkflowManager,
|
||||||
|
private readonly sharedCredentialsRepository: SharedCredentialsRepository,
|
||||||
|
private readonly enterpriseCredentialsService: EnterpriseCredentialsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async shareWithProjects(
|
async shareWithProjects(
|
||||||
|
@ -46,9 +50,17 @@ export class EnterpriseWorkflowService {
|
||||||
) {
|
) {
|
||||||
const em = entityManager ?? this.sharedWorkflowRepository.manager;
|
const em = entityManager ?? this.sharedWorkflowRepository.manager;
|
||||||
|
|
||||||
const projects = await em.find(Project, {
|
let projects = await em.find(Project, {
|
||||||
where: { id: In(shareWithIds), type: 'personal' },
|
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
|
const newSharedWorkflows = projects
|
||||||
// We filter by role === 'project:personalOwner' above and there should
|
// 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
|
// 1. get workflow
|
||||||
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
|
const workflow = await this.sharedWorkflowRepository.findWorkflowForUser(workflowId, user, [
|
||||||
'workflow:move',
|
'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) {
|
if (wasActive) {
|
||||||
try {
|
try {
|
||||||
await this.activeWorkflowManager.add(workflowId, 'update');
|
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';
|
import { GlobalConfig } from '@n8n/config';
|
||||||
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
|
||||||
import { In, type FindOptionsRelations } from '@n8n/typeorm';
|
import { In, type FindOptionsRelations } from '@n8n/typeorm';
|
||||||
|
@ -7,7 +11,6 @@ import express from 'express';
|
||||||
import { Logger } from 'n8n-core';
|
import { Logger } from 'n8n-core';
|
||||||
import { ApplicationError } from 'n8n-workflow';
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import type { Project } from '@/databases/entities/project';
|
import type { Project } from '@/databases/entities/project';
|
||||||
import { SharedWorkflow } from '@/databases/entities/shared-workflow';
|
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 { TagRepository } from '@/databases/repositories/tag.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import * as Db from '@/db';
|
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 { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
|
@ -402,11 +417,10 @@ export class WorkflowsController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Licensed('feat:sharing')
|
||||||
@Put('/:workflowId/share')
|
@Put('/:workflowId/share')
|
||||||
@ProjectScope('workflow:share')
|
@ProjectScope('workflow:share')
|
||||||
async share(req: WorkflowRequest.Share) {
|
async share(req: WorkflowRequest.Share) {
|
||||||
if (!this.license.isSharingEnabled()) throw new NotFoundError('Route not found');
|
|
||||||
|
|
||||||
const { workflowId } = req.params;
|
const { workflowId } = req.params;
|
||||||
const { shareWithIds } = req.body;
|
const { shareWithIds } = req.body;
|
||||||
|
|
||||||
|
@ -472,13 +486,17 @@ export class WorkflowsController {
|
||||||
|
|
||||||
@Put('/:workflowId/transfer')
|
@Put('/:workflowId/transfer')
|
||||||
@ProjectScope('workflow:move')
|
@ProjectScope('workflow:move')
|
||||||
async transfer(req: WorkflowRequest.Transfer) {
|
async transfer(
|
||||||
const body = z.object({ destinationProjectId: z.string() }).parse(req.body);
|
req: AuthenticatedRequest,
|
||||||
|
_res: unknown,
|
||||||
|
@Param('workflowId') workflowId: string,
|
||||||
|
@Body body: TransferWorkflowBodyDto,
|
||||||
|
) {
|
||||||
return await this.enterpriseWorkflowService.transferOne(
|
return await this.enterpriseWorkflowService.transferOne(
|
||||||
req.user,
|
req.user,
|
||||||
req.params.workflowId,
|
workflowId,
|
||||||
body.destinationProjectId,
|
body.destinationProjectId,
|
||||||
|
body.shareCredentials,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -951,6 +951,57 @@ describe('PUT /credentials/:id/share', () => {
|
||||||
expect(mailer.notifyCredentialsShared).toHaveBeenCalledTimes(1);
|
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 () => {
|
test('should respond 400 if invalid payload is provided', async () => {
|
||||||
const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner });
|
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 { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { Telemetry } from '@/telemetry';
|
import { Telemetry } from '@/telemetry';
|
||||||
import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
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 * as testDb from '../shared/test-db';
|
||||||
import {
|
import {
|
||||||
FIRST_CREDENTIAL_ID,
|
FIRST_CREDENTIAL_ID,
|
||||||
|
@ -33,6 +33,8 @@ describe('EnterpriseWorkflowService', () => {
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
mock(),
|
mock(),
|
||||||
|
mock(),
|
||||||
|
mock(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -8,18 +8,28 @@ import config from '@/config';
|
||||||
import type { Project } from '@/databases/entities/project';
|
import type { Project } from '@/databases/entities/project';
|
||||||
import type { User } from '@/databases/entities/user';
|
import type { User } from '@/databases/entities/user';
|
||||||
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
import { ProjectRepository } from '@/databases/repositories/project.repository';
|
||||||
|
import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository';
|
||||||
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
|
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
|
||||||
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
import { WorkflowRepository } from '@/databases/repositories/workflow.repository';
|
||||||
import { License } from '@/license';
|
|
||||||
import { UserManagementMailer } from '@/user-management/email';
|
import { UserManagementMailer } from '@/user-management/email';
|
||||||
import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types';
|
import type { WorkflowWithSharingsMetaDataAndCredentials } from '@/workflows/workflows.types';
|
||||||
|
import { mockInstance } from '@test/mocking';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
import {
|
||||||
import { affixRoleToSaveCredential, shareCredentialWithUsers } from '../shared/db/credentials';
|
affixRoleToSaveCredential,
|
||||||
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
|
getCredentialSharings,
|
||||||
|
shareCredentialWithProjects,
|
||||||
|
shareCredentialWithUsers,
|
||||||
|
} from '../shared/db/credentials';
|
||||||
|
import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects';
|
||||||
import { createTag } from '../shared/db/tags';
|
import { createTag } from '../shared/db/tags';
|
||||||
import { createAdmin, createOwner, createUser, createUserShell } from '../shared/db/users';
|
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 { randomCredentialPayload } from '../shared/random';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SaveCredentialFunction } from '../shared/types';
|
import type { SaveCredentialFunction } from '../shared/types';
|
||||||
|
@ -44,7 +54,6 @@ let workflowRepository: WorkflowRepository;
|
||||||
|
|
||||||
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
|
const activeWorkflowManager = mockInstance(ActiveWorkflowManager);
|
||||||
|
|
||||||
const sharingSpy = jest.spyOn(License.prototype, 'isSharingEnabled').mockReturnValue(true);
|
|
||||||
const testServer = utils.setupTestServer({
|
const testServer = utils.setupTestServer({
|
||||||
endpointGroups: ['workflows'],
|
endpointGroups: ['workflows'],
|
||||||
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
|
enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'],
|
||||||
|
@ -95,15 +104,15 @@ describe('router should switch based on flag', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when sharing is disabled', async () => {
|
test('when sharing is disabled', async () => {
|
||||||
sharingSpy.mockReturnValueOnce(false);
|
license.disable('feat:sharing');
|
||||||
|
|
||||||
await authOwnerAgent
|
await authOwnerAgent
|
||||||
.put(`/workflows/${savedWorkflowId}/share`)
|
.put(`/workflows/${savedWorkflowId}/share`)
|
||||||
.send({ shareWithIds: [memberPersonalProject.id] })
|
.send({ shareWithIds: [memberPersonalProject.id] })
|
||||||
.expect(404);
|
.expect(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('when sharing is enabled', async () => {
|
test('when sharing is enabled', async () => {
|
||||||
|
license.enable('feat:sharing');
|
||||||
await authOwnerAgent
|
await authOwnerAgent
|
||||||
.put(`/workflows/${savedWorkflowId}/share`)
|
.put(`/workflows/${savedWorkflowId}/share`)
|
||||||
.send({ shareWithIds: [memberPersonalProject.id] })
|
.send({ shareWithIds: [memberPersonalProject.id] })
|
||||||
|
@ -290,6 +299,52 @@ describe('PUT /workflows/:workflowId/share', () => {
|
||||||
|
|
||||||
config.set('userManagement.emails.mode', 'smtp');
|
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', () => {
|
describe('GET /workflows/new', () => {
|
||||||
|
@ -297,7 +352,7 @@ describe('GET /workflows/new', () => {
|
||||||
test(`should return an auto-incremented name, even when sharing is ${
|
test(`should return an auto-incremented name, even when sharing is ${
|
||||||
sharingEnabled ? 'enabled' : 'disabled'
|
sharingEnabled ? 'enabled' : 'disabled'
|
||||||
}`, async () => {
|
}`, async () => {
|
||||||
sharingSpy.mockReturnValueOnce(sharingEnabled);
|
license.enable('feat:sharing');
|
||||||
|
|
||||||
await createWorkflow({ name: 'My workflow' }, owner);
|
await createWorkflow({ name: 'My workflow' }, owner);
|
||||||
await createWorkflow({ name: 'My workflow 7' }, owner);
|
await createWorkflow({ name: 'My workflow 7' }, owner);
|
||||||
|
@ -1602,6 +1657,338 @@ describe('PUT /:workflowId/transfer', () => {
|
||||||
expect(workflowFromDB).toMatchObject({ active: false });
|
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 () => {
|
test('returns a 500 if the workflow cannot be activated due to an unknown error', async () => {
|
||||||
//
|
//
|
||||||
// ARRANGE
|
// ARRANGE
|
||||||
|
|
|
@ -38,9 +38,13 @@ let anotherMember: User;
|
||||||
let authOwnerAgent: SuperAgentTest;
|
let authOwnerAgent: SuperAgentTest;
|
||||||
let authMemberAgent: 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 license = testServer.license;
|
||||||
|
|
||||||
const { objectContaining, arrayContaining, any } = expect;
|
const { objectContaining, arrayContaining, any } = expect;
|
||||||
|
@ -48,9 +52,19 @@ const { objectContaining, arrayContaining, any } = expect;
|
||||||
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
|
const activeWorkflowManagerLike = mockInstance(ActiveWorkflowManager);
|
||||||
|
|
||||||
let projectRepository: ProjectRepository;
|
let projectRepository: ProjectRepository;
|
||||||
|
let projectService: ProjectService;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeEach(async () => {
|
||||||
|
await testDb.truncate([
|
||||||
|
'Workflow',
|
||||||
|
'SharedWorkflow',
|
||||||
|
'Tag',
|
||||||
|
'WorkflowHistory',
|
||||||
|
'Project',
|
||||||
|
'ProjectRelation',
|
||||||
|
]);
|
||||||
projectRepository = Container.get(ProjectRepository);
|
projectRepository = Container.get(ProjectRepository);
|
||||||
|
projectService = Container.get(ProjectService);
|
||||||
owner = await createOwner();
|
owner = await createOwner();
|
||||||
authOwnerAgent = testServer.authAgentFor(owner);
|
authOwnerAgent = testServer.authAgentFor(owner);
|
||||||
member = await createMember();
|
member = await createMember();
|
||||||
|
@ -58,9 +72,8 @@ beforeAll(async () => {
|
||||||
anotherMember = await createMember();
|
anotherMember = await createMember();
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
afterEach(() => {
|
||||||
jest.resetAllMocks();
|
jest.clearAllMocks();
|
||||||
await testDb.truncate(['Workflow', 'SharedWorkflow', 'Tag', 'WorkflowHistory']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /workflows', () => {
|
describe('POST /workflows', () => {
|
||||||
|
@ -271,7 +284,7 @@ describe('POST /workflows', () => {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Container.get(ProjectService).addUser(project.id, owner.id, 'project:admin');
|
await projectService.addUser(project.id, owner.id, 'project:admin');
|
||||||
|
|
||||||
//
|
//
|
||||||
// ACT
|
// ACT
|
||||||
|
@ -345,7 +358,7 @@ describe('POST /workflows', () => {
|
||||||
type: 'team',
|
type: 'team',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Container.get(ProjectService).addUser(project.id, member.id, 'project:viewer');
|
await projectService.addUser(project.id, member.id, 'project:viewer');
|
||||||
|
|
||||||
//
|
//
|
||||||
// ACT
|
// ACT
|
||||||
|
|
|
@ -43,6 +43,10 @@
|
||||||
font-size: var.$notification-title-font-size;
|
font-size: var.$notification-title-font-size;
|
||||||
color: var.$notification-title-color;
|
color: var.$notification-title-color;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
&:first-letter {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mixins.e(content) {
|
@include mixins.e(content) {
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { TransferWorkflowBodyDto } from '@n8n/api-types';
|
||||||
import type { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface';
|
import type { IRestApiContext, IShareWorkflowsPayload, IWorkflowsShareResponse } from '@/Interface';
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
@ -18,9 +19,7 @@ export async function setWorkflowSharedWith(
|
||||||
export async function moveWorkflowToProject(
|
export async function moveWorkflowToProject(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
id: string,
|
id: string,
|
||||||
destinationProjectId: string,
|
body: TransferWorkflowBodyDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, {
|
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body);
|
||||||
destinationProjectId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import { createTestWorkflow } from '@/__tests__/mocks';
|
||||||
import { createProjectListItem } from '@/__tests__/data/projects';
|
import { createProjectListItem } from '@/__tests__/data/projects';
|
||||||
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
import { getDropdownItems, mockedStore } from '@/__tests__/utils';
|
||||||
import type { 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 ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
|
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
|
||||||
pinia: createTestingPinia(),
|
pinia: createTestingPinia(),
|
||||||
|
@ -23,26 +26,32 @@ const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
|
||||||
|
|
||||||
let telemetry: ReturnType<typeof useTelemetry>;
|
let telemetry: ReturnType<typeof useTelemetry>;
|
||||||
let projectsStore: MockedStore<typeof useProjectsStore>;
|
let projectsStore: MockedStore<typeof useProjectsStore>;
|
||||||
|
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
|
||||||
|
let credentialsStore: MockedStore<typeof useCredentialsStore>;
|
||||||
|
|
||||||
describe('ProjectMoveResourceModal', () => {
|
describe('ProjectMoveResourceModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
telemetry = useTelemetry();
|
telemetry = useTelemetry();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
workflowsStore = mockedStore(useWorkflowsStore);
|
||||||
|
credentialsStore = mockedStore(useCredentialsStore);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send telemetry when mounted', async () => {
|
it('should send telemetry when mounted', async () => {
|
||||||
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
|
||||||
|
|
||||||
projectsStore.availableProjects = [createProjectListItem()];
|
projectsStore.availableProjects = [createProjectListItem()];
|
||||||
|
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
data: {
|
data: {
|
||||||
resourceType: 'workflow',
|
resourceType: 'workflow',
|
||||||
resourceTypeLabel: 'Workflow',
|
resourceTypeLabel: 'workflow',
|
||||||
resource: {
|
resource: {
|
||||||
id: '1',
|
id: '1',
|
||||||
|
name: 'My Workflow',
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'My Project',
|
name: 'My Project',
|
||||||
|
@ -59,14 +68,16 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
|
|
||||||
it('should show no available projects message', async () => {
|
it('should show no available projects message', async () => {
|
||||||
projectsStore.availableProjects = [];
|
projectsStore.availableProjects = [];
|
||||||
|
workflowsStore.fetchWorkflow.mockResolvedValueOnce(createTestWorkflow());
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
modalName: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
data: {
|
data: {
|
||||||
resourceType: 'workflow',
|
resourceType: 'workflow',
|
||||||
resourceTypeLabel: 'Workflow',
|
resourceTypeLabel: 'workflow',
|
||||||
resource: {
|
resource: {
|
||||||
id: '1',
|
id: '1',
|
||||||
|
name: 'My Workflow',
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'My Project',
|
name: 'My Project',
|
||||||
|
@ -89,6 +100,7 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
resourceTypeLabel: 'Workflow',
|
resourceTypeLabel: 'Workflow',
|
||||||
resource: {
|
resource: {
|
||||||
id: '1',
|
id: '1',
|
||||||
|
name: 'My Workflow',
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: projects[0].id,
|
id: projects[0].id,
|
||||||
name: projects[0].name,
|
name: projects[0].name,
|
||||||
|
@ -112,4 +124,118 @@ describe('ProjectMoveResourceModal', () => {
|
||||||
|
|
||||||
expect(projectSelect).toBeVisible();
|
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>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted, h } from 'vue';
|
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 { useI18n } from '@/composables/useI18n';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
@ -10,10 +11,13 @@ import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
|
||||||
|
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { sortByProperty } from '@/utils/sortUtils';
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
|
@ -29,11 +33,40 @@ const i18n = useI18n();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
const credentialsStore = useCredentialsStore();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const filter = ref('');
|
const filter = ref('');
|
||||||
const projectId = ref<string | null>(null);
|
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 ?? '') ?? '',
|
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
|
||||||
);
|
);
|
||||||
const availableProjects = computed(() =>
|
const availableProjects = computed(() =>
|
||||||
|
@ -53,6 +86,12 @@ const selectedProject = computed(() =>
|
||||||
availableProjects.value.find((p) => p.id === projectId.value),
|
availableProjects.value.find((p) => p.id === projectId.value),
|
||||||
);
|
);
|
||||||
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
|
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) =>
|
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
|
||||||
resource.homeProject?.type === ProjectTypes.Team;
|
resource.homeProject?.type === ProjectTypes.Team;
|
||||||
|
@ -81,6 +120,7 @@ const moveResource = async () => {
|
||||||
props.data.resourceType,
|
props.data.resourceType,
|
||||||
props.data.resource.id,
|
props.data.resource.id,
|
||||||
selectedProject.value.id,
|
selectedProject.value.id,
|
||||||
|
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
|
||||||
);
|
);
|
||||||
closeModal();
|
closeModal();
|
||||||
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
|
||||||
|
@ -91,17 +131,17 @@ const moveResource = async () => {
|
||||||
title: i18n.baseText('projects.move.resource.success.title', {
|
title: i18n.baseText('projects.move.resource.success.title', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
resourceTypeLabel: props.data.resourceTypeLabel,
|
||||||
|
resourceName: resourceName.value,
|
||||||
|
targetProjectName: targetProjectName.value,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
message: h(ProjectMoveSuccessToastMessage, {
|
message: h(ProjectMoveSuccessToastMessage, {
|
||||||
routeName:
|
routeName: isResourceWorkflow.value ? VIEWS.PROJECTS_WORKFLOWS : VIEWS.PROJECTS_CREDENTIALS,
|
||||||
props.data.resourceType === ResourceType.Workflow
|
|
||||||
? VIEWS.PROJECTS_WORKFLOWS
|
|
||||||
: VIEWS.PROJECTS_CREDENTIALS,
|
|
||||||
resource: props.data.resource,
|
|
||||||
resourceType: props.data.resourceType,
|
resourceType: props.data.resourceType,
|
||||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
|
||||||
targetProject: selectedProject.value,
|
targetProject: selectedProject.value,
|
||||||
|
isShareCredentialsChecked: shareUsedCredentials.value,
|
||||||
|
areAllUsedCredentialsShareable:
|
||||||
|
shareableCredentials.value.length === usedCredentials.value.length,
|
||||||
}),
|
}),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
duration: 8000,
|
duration: 8000,
|
||||||
|
@ -119,18 +159,28 @@ const moveResource = async () => {
|
||||||
i18n.baseText('projects.move.resource.error.title', {
|
i18n.baseText('projects.move.resource.error.title', {
|
||||||
interpolate: {
|
interpolate: {
|
||||||
resourceTypeLabel: props.data.resourceTypeLabel,
|
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}`, {
|
telemetry.track(`User clicked to move a ${props.data.resourceType}`, {
|
||||||
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
[`${props.data.resourceType}_id`]: props.data.resource.id,
|
||||||
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
@ -146,19 +196,19 @@ onMounted(() => {
|
||||||
<N8nText>
|
<N8nText>
|
||||||
<i18n-t keypath="projects.move.resource.modal.message">
|
<i18n-t keypath="projects.move.resource.modal.message">
|
||||||
<template #resourceName
|
<template #resourceName
|
||||||
><strong>{{ props.data.resource.name }}</strong></template
|
><strong>{{ resourceName }}</strong></template
|
||||||
>
|
>
|
||||||
<template v-if="isResourceInTeamProject" #inTeamProject>
|
<template v-if="isResourceInTeamProject" #inTeamProject>
|
||||||
<i18n-t keypath="projects.move.resource.modal.message.team">
|
<i18n-t keypath="projects.move.resource.modal.message.team">
|
||||||
<template #resourceHomeProjectName
|
<template #resourceHomeProjectName
|
||||||
><strong>{{ processedName }}</strong></template
|
><strong>{{ homeProjectName }}</strong></template
|
||||||
>
|
>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</template>
|
</template>
|
||||||
<template v-else #inPersonalProject>
|
<template v-else #inPersonalProject>
|
||||||
<i18n-t keypath="projects.move.resource.modal.message.personal">
|
<i18n-t keypath="projects.move.resource.modal.message.personal">
|
||||||
<template #resourceHomeProjectName
|
<template #resourceHomeProjectName
|
||||||
><strong>{{ processedName }}</strong></template
|
><strong>{{ homeProjectName }}</strong></template
|
||||||
>
|
>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</template>
|
</template>
|
||||||
|
@ -195,8 +245,10 @@ onMounted(() => {
|
||||||
>
|
>
|
||||||
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<span v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0">
|
<span
|
||||||
<br />
|
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
|
||||||
|
:class="$style.textBlock"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
|
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
|
||||||
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
adjustToNumber: props.data.resource.sharedWithProjects?.length,
|
||||||
|
@ -206,6 +258,50 @@ onMounted(() => {
|
||||||
})
|
})
|
||||||
}}</span
|
}}</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>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<N8nText v-else>{{
|
<N8nText v-else>{{
|
||||||
|
@ -219,7 +315,12 @@ onMounted(() => {
|
||||||
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
|
||||||
{{ i18n.baseText('generic.cancel') }}
|
{{ i18n.baseText('generic.cancel') }}
|
||||||
</N8nButton>
|
</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', {
|
i18n.baseText('projects.move.resource.modal.button', {
|
||||||
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
|
||||||
|
@ -236,4 +337,13 @@ onMounted(() => {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.textBlock {
|
||||||
|
display: block;
|
||||||
|
margin-top: var(--spacing-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltipText {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</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 () => {
|
it('should show credentials message if the resource is a workflow', async () => {
|
||||||
const props = {
|
const props = {
|
||||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
resource: {
|
|
||||||
id: '1',
|
|
||||||
name: 'My Workflow',
|
|
||||||
homeProject: {
|
|
||||||
id: '2',
|
|
||||||
name: 'My Project',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resourceType: ResourceType.Workflow,
|
resourceType: ResourceType.Workflow,
|
||||||
resourceTypeLabel: 'Workflow',
|
|
||||||
targetProject: {
|
targetProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'My Project',
|
name: 'My Project',
|
||||||
},
|
},
|
||||||
|
isShareCredentialsChecked: false,
|
||||||
|
areAllUsedCredentialsShareable: false,
|
||||||
};
|
};
|
||||||
const { getByText } = renderComponent({ props });
|
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 () => {
|
it('should show link if the target project type is team project', async () => {
|
||||||
const props = {
|
const props = {
|
||||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
resource: {
|
|
||||||
id: '1',
|
|
||||||
name: 'My Workflow',
|
|
||||||
homeProject: {
|
|
||||||
id: '2',
|
|
||||||
name: 'My Project',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resourceType: ResourceType.Workflow,
|
resourceType: ResourceType.Workflow,
|
||||||
resourceTypeLabel: 'workflow',
|
|
||||||
targetProject: {
|
targetProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Team Project',
|
name: 'Team Project',
|
||||||
type: ProjectTypes.Team,
|
type: ProjectTypes.Team,
|
||||||
},
|
},
|
||||||
|
isShareCredentialsChecked: false,
|
||||||
|
areAllUsedCredentialsShareable: false,
|
||||||
};
|
};
|
||||||
const { getByRole } = renderComponent({ props });
|
const { getByRole } = renderComponent({ props });
|
||||||
expect(getByRole('link')).toBeInTheDocument();
|
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 () => {
|
it('should show only general if the resource is credential and moved to a personal project', async () => {
|
||||||
const props = {
|
const props = {
|
||||||
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
routeName: VIEWS.PROJECTS_WORKFLOWS,
|
||||||
resource: {
|
|
||||||
id: '1',
|
|
||||||
name: 'Notion API',
|
|
||||||
homeProject: {
|
|
||||||
id: '2',
|
|
||||||
name: 'My Project',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
resourceType: ResourceType.Credential,
|
resourceType: ResourceType.Credential,
|
||||||
resourceTypeLabel: 'credential',
|
|
||||||
targetProject: {
|
targetProject: {
|
||||||
id: '2',
|
id: '2',
|
||||||
name: 'Personal Project',
|
name: 'Personal Project',
|
||||||
type: ProjectTypes.Personal,
|
type: ProjectTypes.Personal,
|
||||||
},
|
},
|
||||||
|
isShareCredentialsChecked: false,
|
||||||
|
areAllUsedCredentialsShareable: false,
|
||||||
};
|
};
|
||||||
const { getByText, queryByText, queryByRole } = renderComponent({ props });
|
const { queryByText, queryByRole } = renderComponent({ props });
|
||||||
expect(getByText(/credential was moved to /)).toBeInTheDocument();
|
expect(queryByText(/The workflow's credentials were not shared/)).not.toBeInTheDocument();
|
||||||
expect(queryByText(/Please double check any credentials/)).not.toBeInTheDocument();
|
|
||||||
expect(queryByRole('link')).not.toBeInTheDocument();
|
expect(queryByRole('link')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,57 +1,52 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { truncate } from 'n8n-design-system';
|
import { truncate } from 'n8n-design-system';
|
||||||
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
|
|
||||||
import { ResourceType, splitName } from '@/utils/projects.utils';
|
import { ResourceType, splitName } from '@/utils/projects.utils';
|
||||||
import type { ProjectListItem } from '@/types/projects.types';
|
import type { ProjectListItem } from '@/types/projects.types';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
routeName: string;
|
routeName: string;
|
||||||
resource: IWorkflowDb | ICredentialsResponse;
|
|
||||||
resourceType: ResourceType;
|
resourceType: ResourceType;
|
||||||
resourceTypeLabel: string;
|
|
||||||
targetProject: ProjectListItem;
|
targetProject: ProjectListItem;
|
||||||
|
isShareCredentialsChecked: boolean;
|
||||||
|
areAllUsedCredentialsShareable: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
|
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
|
||||||
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
|
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
|
||||||
const projectName = computed(() => {
|
const targetProjectName = computed(() => {
|
||||||
const { name, email } = splitName(props.targetProject?.name ?? '');
|
const { name, email } = splitName(props.targetProject?.name ?? '');
|
||||||
return truncate(name ?? email ?? '', 25);
|
return truncate(name ?? email ?? '', 25);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<i18n-t keypath="projects.move.resource.success.message">
|
<div>
|
||||||
<template #resourceTypeLabel>{{ props.resourceTypeLabel }}</template>
|
<N8nText v-if="isWorkflow" tag="p" class="pt-xs">
|
||||||
<template #resourceName
|
<span v-if="props.isShareCredentialsChecked && props.areAllUsedCredentialsShareable">{{
|
||||||
><strong>{{ props.resource.name }}</strong></template
|
i18n.baseText('projects.move.resource.success.message.workflow.withAllCredentials')
|
||||||
>
|
}}</span>
|
||||||
<template #targetProjectName
|
<span v-else-if="props.isShareCredentialsChecked">{{
|
||||||
><strong>{{ projectName }}</strong></template
|
i18n.baseText('projects.move.resource.success.message.workflow.withSomeCredentials')
|
||||||
>
|
}}</span>
|
||||||
<template v-if="isWorkflow" #workflow>
|
<span v-else>{{ i18n.baseText('projects.move.resource.success.message.workflow') }}</span>
|
||||||
<N8nText tag="p" class="pt-xs">
|
</N8nText>
|
||||||
<i18n-t keypath="projects.move.resource.success.message.workflow">
|
<p v-if="isTargetProjectTeam" class="pt-s">
|
||||||
<template #targetProjectName
|
<router-link
|
||||||
><strong>{{ projectName }}</strong></template
|
:to="{
|
||||||
>
|
name: props.routeName,
|
||||||
</i18n-t>
|
params: { projectId: props.targetProject.id },
|
||||||
</N8nText>
|
}"
|
||||||
</template>
|
>
|
||||||
<template v-if="isTargetProjectTeam" #link>
|
{{
|
||||||
<p class="pt-s">
|
i18n.baseText('projects.move.resource.success.link', {
|
||||||
<router-link
|
interpolate: { targetProjectName },
|
||||||
:to="{
|
})
|
||||||
name: props.routeName,
|
}}
|
||||||
params: { projectId: props.targetProject.id },
|
</router-link>
|
||||||
}"
|
</p>
|
||||||
>
|
</div>
|
||||||
<i18n-t keypath="projects.move.resource.success.link">
|
|
||||||
<template #targetProjectName>{{ projectName }}</template>
|
|
||||||
</i18n-t>
|
|
||||||
</router-link>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2644,13 +2644,18 @@
|
||||||
"projects.move.resource.modal.message.note": "Note",
|
"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.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.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.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.button": "Move {resourceTypeLabel}",
|
||||||
"projects.move.resource.modal.selectPlaceholder": "Select project or user...",
|
"projects.move.resource.modal.selectPlaceholder": "Select project or user...",
|
||||||
"projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}",
|
"projects.move.resource.error.title": "Error moving {resourceName} {resourceTypeLabel}",
|
||||||
"projects.move.resource.success.title": "Successfully moved {resourceTypeLabel}",
|
"projects.move.resource.success.title": "{resourceTypeLabel} '{resourceName}' is moved to '{targetProjectName}'",
|
||||||
"projects.move.resource.success.message": "{resourceName} {resourceTypeLabel} was moved to {targetProjectName}. {workflow} {link}",
|
"projects.move.resource.success.message.workflow": "The workflow's credentials were not shared with the project.",
|
||||||
"projects.move.resource.success.message.workflow": "Please double check any credentials this workflow is using are also shared with {targetProjectName}.",
|
"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.move.resource.success.link": "View {targetProjectName}",
|
||||||
"projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with {count} users",
|
"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",
|
"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',
|
resourceType: 'workflow' | 'credential',
|
||||||
resourceId: string,
|
resourceId: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
shareCredentials?: string[],
|
||||||
) => {
|
) => {
|
||||||
if (resourceType === 'workflow') {
|
if (resourceType === 'workflow') {
|
||||||
await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, projectId);
|
await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, {
|
||||||
|
destinationProjectId: projectId,
|
||||||
|
shareCredentials,
|
||||||
|
});
|
||||||
await workflowsStore.fetchAllWorkflows(currentProjectId.value);
|
await workflowsStore.fetchAllWorkflows(currentProjectId.value);
|
||||||
} else {
|
} else {
|
||||||
await credentialsEEApi.moveCredentialToProject(
|
await credentialsEEApi.moveCredentialToProject(
|
||||||
|
|
Loading…
Reference in a new issue