This commit is contained in:
Csaba Tuncsik 2024-09-19 21:46:39 +05:30 committed by GitHub
commit 1d80ebf2ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 749 additions and 725 deletions

View file

@ -32,8 +32,6 @@ export const addProjectMember = (email: string, role?: string) => {
}
};
export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal');
export const getResourceMoveConfirmModal = () =>
cy.getByTestId('project-move-resource-confirm-modal');
export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select');
export function createProject(name: string) {

View file

@ -1,5 +1,11 @@
import * as projects from '../composables/projects';
import { INSTANCE_MEMBERS, MANUAL_TRIGGER_NODE_NAME, NOTION_NODE_NAME } from '../constants';
import {
INSTANCE_ADMIN,
INSTANCE_MEMBERS,
INSTANCE_OWNER,
MANUAL_TRIGGER_NODE_NAME,
NOTION_NODE_NAME,
} from '../constants';
import {
WorkflowsPage,
WorkflowPage,
@ -481,44 +487,15 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 2)
.first()
.should('contain.text', 'Project 1')
.click();
projects.getResourceMoveModal().find('button:contains("Next")').click();
projects
.getResourceMoveConfirmModal()
.should('be.visible')
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.first()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.last()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('not.be.disabled')
.should('have.length', 5)
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters
.workflowCards()
@ -526,9 +503,77 @@ describe('Projects', { disableAutoLogin: true }, () => {
.filter(':contains("Owned by me")')
.should('not.exist');
// Move the credential from Project 1 to Project 2
// Move the workflow from Project 1 to Project 2
projects.getMenuItems().first().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
// Move the workflow from Project 2 to a member user
projects.getMenuItems().last().click();
workflowsPage.getters.workflowCards().should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_MEMBERS[0].email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters.workflowCards().should('have.length', 1);
// Move the workflow from member user back to Home
projects.getHomeButton().click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':has(.n8n-badge:contains("Project"))')
.should('have.length', 2);
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
workflowsPage.getters.workflowMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move workflow")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_OWNER.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move workflow")').click();
workflowsPage.getters
.workflowCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('have.length', 1);
// Move the credential from Project 1 to Project 2
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 1);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
@ -537,48 +582,90 @@ describe('Projects', { disableAutoLogin: true }, () => {
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Next")')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 1)
.first()
.should('contain.text', 'Project 2')
.should('have.length', 5)
.filter(':contains("Project 2")')
.click();
projects.getResourceMoveModal().find('button:contains("Next")').click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
projects
.getResourceMoveConfirmModal()
.should('be.visible')
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.first()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('be.disabled');
projects
.getResourceMoveConfirmModal()
.find('input[type="checkbox"]')
.last()
.parents('label')
.click();
projects
.getResourceMoveConfirmModal()
.find('button:contains("Confirm")')
.should('not.be.disabled')
.click();
credentialsPage.getters.credentialCards().should('not.have.length');
// Move the credential from Project 2 to admin user
projects.getMenuItems().last().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 2);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_ADMIN.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
credentialsPage.getters.credentialCards().should('have.length', 1);
// Move the credential from admin user back to instance owner
projects.getHomeButton().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters.credentialCards().should('have.length', 3);
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(`:contains("${INSTANCE_OWNER.email}")`)
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
credentialsPage.getters
.credentialCards()
.should('have.length', 3)
.filter(':contains("Owned by me")')
.should('have.length', 2);
// Move the credential from admin user back to its original project (Project 1)
credentialsPage.getters.credentialCardActions('Credential in Project 1').click();
credentialsPage.getters.credentialMoveButton().click();
projects
.getResourceMoveModal()
.should('be.visible')
.find('button:contains("Move credential")')
.should('be.disabled');
projects.getProjectMoveSelect().click();
getVisibleSelect()
.find('li')
.should('have.length', 5)
.filter(':contains("Project 1")')
.click();
projects.getResourceMoveModal().find('button:contains("Move credential")').click();
projects.getMenuItems().first().click();
projects.getProjectTabCredentials().click();
credentialsPage.getters
.credentialCards()
.filter(':contains("Credential in Project 1")')
.should('have.length', 1);
});
it('should handle viewer role', () => {

View file

@ -157,14 +157,6 @@ export class EnterpriseCredentialsService {
"You can't transfer a credential into the project that's already owning it.",
);
}
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
throw new TransferCredentialError(
'You can only transfer credentials out of personal or team projects.',
);
}
if (destinationProject.type !== 'team') {
throw new TransferCredentialError('You can only transfer credentials into team projects.');
}
await this.sharedCredentialsRepository.manager.transaction(async (trx) => {
// 6. transfer the credential

View file

@ -285,14 +285,6 @@ export class EnterpriseWorkflowService {
"You can't transfer a workflow into the project that's already owning it.",
);
}
if (sourceProject.type !== 'team' && sourceProject.type !== 'personal') {
throw new TransferWorkflowError(
'You can only transfer workflows out of personal or team projects.',
);
}
if (destinationProject.type !== 'team') {
throw new TransferWorkflowError('You can only transfer workflows into team projects.');
}
// 6. deactivate workflow if necessary
const wasActive = workflow.active;

View file

@ -3,6 +3,7 @@ import { Container } from 'typedi';
import config from '@/config';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { SharedCredentialsRepository } from '@/databases/repositories/shared-credentials.repository';
@ -1118,18 +1119,6 @@ describe('PUT /:credentialId/transfer', () => {
.expect(400);
});
test('cannot transfer into a personal project', async () => {
const credential = await saveCredential(randomCredentialPayload(), {
user: member,
});
await testServer
.authAgentFor(member)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: memberPersonalProject.id })
.expect(400);
});
test('cannot transfer somebody elses credential', async () => {
const destinationProject = await createTeamProject('Destination Project', member);
@ -1158,187 +1147,139 @@ describe('PUT /:credentialId/transfer', () => {
.expect(404);
});
test('project:editors cannot transfer credentials', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project');
await linkUserToProject(member, sourceProject, 'project:editor');
const credential = await saveCredential(randomCredentialPayload(), {
project: sourceProject,
});
const destinationProject = await createTeamProject('Destination Project', member);
//
// ACT & ASSERT
//
await testServer
.authAgentFor(member)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(403);
});
test('transferring from a personal project to a team project severs all sharings', async () => {
//
// ARRANGE
//
const credential = await saveCredential(randomCredentialPayload(), { user: member });
// these sharings should be deleted by the transfer
await shareCredentialWithUsers(credential, [anotherMember, owner]);
const destinationProject = await createTeamProject('Destination Project', member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getCredentialSharings(credential);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:owner',
});
});
test('can transfer from team to another team project', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Team Project 1', member);
const credential = await saveCredential(randomCredentialPayload(), {
project: sourceProject,
});
const destinationProject = await createTeamProject('Team Project 2', member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getCredentialSharings(credential);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
credentialsId: credential.id,
role: 'credential:owner',
});
});
test.each([
['owners', () => owner],
['admins', () => admin],
])(
'%s can always transfer from any personal or team project into any team project',
async (_name, actor) => {
test.each<ProjectRole>(['project:editor', 'project:viewer'])(
'%ss cannot transfer credentials',
async (projectRole) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const teamCredential = await saveCredential(randomCredentialPayload(), {
const sourceProject = await createTeamProject('Source Project');
await linkUserToProject(member, sourceProject, projectRole);
const credential = await saveCredential(randomCredentialPayload(), {
project: sourceProject,
});
const personalCredential = await saveCredential(randomCredentialPayload(), { user: member });
const destinationProject = await createTeamProject('Destination Project', member);
//
// ACT & ASSERT
//
await testServer
.authAgentFor(member)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(403);
},
);
test.each<
[
// user role
'owners' | 'admins',
// source project type
'team' | 'personal',
// destination project type
'team' | 'personal',
// actor
() => User,
// source project
() => Promise<Project> | Project,
// destination project
() => Promise<Project> | Project,
]
>([
// owner
[
'owners',
'team',
'team',
() => owner,
async () => await createTeamProject('Source Project'),
async () => await createTeamProject('Destination Project'),
],
[
'owners',
'team',
'personal',
() => owner,
async () => await createTeamProject('Source Project'),
() => memberPersonalProject,
],
[
'owners',
'personal',
'team',
() => owner,
() => memberPersonalProject,
async () => await createTeamProject('Destination Project'),
],
// admin
[
'admins',
'team',
'team',
() => admin,
async () => await createTeamProject('Source Project'),
async () => await createTeamProject('Destination Project'),
],
[
'admins',
'team',
'personal',
() => admin,
async () => await createTeamProject('Source Project'),
() => memberPersonalProject,
],
[
'admins',
'personal',
'team',
() => admin,
() => memberPersonalProject,
async () => await createTeamProject('Destination Project'),
],
])(
'%s can always transfer from a %s project to a %s project',
async (
_roleName,
_sourceProjectName,
_destinationProjectName,
getUser,
getSourceProject,
getDestinationProject,
) => {
// ARRANGE
const user = getUser();
const sourceProject = await getSourceProject();
const destinationProject = await getDestinationProject();
const credential = await saveCredential(randomCredentialPayload(), {
project: sourceProject,
});
// ACT
//
const response1 = await testServer
.authAgentFor(actor())
.put(`/credentials/${teamCredential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
const response2 = await testServer
.authAgentFor(actor())
.put(`/credentials/${personalCredential.id}/transfer`)
const response = await testServer
.authAgentFor(user)
.put(`/credentials/${credential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response1.body).toEqual({});
expect(response2.body).toEqual({});
expect(response.body).toEqual({});
{
const allSharings = await getCredentialSharings(teamCredential);
const allSharings = await getCredentialSharings(credential);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
credentialsId: teamCredential.id,
role: 'credential:owner',
});
}
{
const allSharings = await getCredentialSharings(personalCredential);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
credentialsId: personalCredential.id,
credentialsId: credential.id,
role: 'credential:owner',
});
}
},
);
test.each([
['owners', () => owner],
['admins', () => admin],
])('%s cannot transfer into personal projects', async (_name, actor) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const teamCredential = await saveCredential(randomCredentialPayload(), {
project: sourceProject,
});
const personalCredential = await saveCredential(randomCredentialPayload(), { user: member });
const destinationProject = anotherMemberPersonalProject;
//
// ACT & ASSERT
//
await testServer
.authAgentFor(actor())
.put(`/credentials/${teamCredential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
await testServer
.authAgentFor(actor())
.put(`/credentials/${personalCredential.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
});
});
function validateMainCredentialData(credential: ListQuery.Credentials.WithOwnedByAndSharedWith) {

View file

@ -5,6 +5,7 @@ import { v4 as uuid } from 'uuid';
import { ActiveWorkflowManager } from '@/active-workflow-manager';
import config from '@/config';
import type { Project } from '@/databases/entities/project';
import type { ProjectRole } from '@/databases/entities/project-relation';
import type { User } from '@/databases/entities/user';
import { ProjectRepository } from '@/databases/repositories/project.repository';
import { WorkflowHistoryRepository } from '@/databases/repositories/workflow-history.repository';
@ -1385,18 +1386,6 @@ describe('PUT /:workflowId/transfer', () => {
.expect(400);
});
test('cannot transfer into a personal project', async () => {
const sourceProject = await createTeamProject('Team Project', member);
const workflow = await createWorkflow({}, sourceProject);
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: memberPersonalProject.id })
.expect(400);
});
test('cannot transfer somebody elses workflow', async () => {
const destinationProject = await createTeamProject('Team Project', member);
@ -1421,180 +1410,133 @@ describe('PUT /:workflowId/transfer', () => {
.expect(404);
});
test('project:editors cannot transfer workflows', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject();
await linkUserToProject(member, sourceProject, 'project:editor');
const workflow = await createWorkflow({}, sourceProject);
const destinationProject = await createTeamProject();
await linkUserToProject(member, destinationProject, 'project:admin');
//
// ACT & ASSERT
//
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(403);
});
test('transferring from a personal project to a team project severs all sharings', async () => {
//
// ARRANGE
//
const workflow = await createWorkflow({}, member);
// these sharings should be deleted by the transfer
await shareWorkflowWithUsers(workflow, [anotherMember, owner]);
const destinationProject = await createTeamProject('Team Project', member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getWorkflowSharing(workflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
});
test('can transfer from team to another team project', async () => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Team Project 1', member);
const workflow = await createWorkflow({}, sourceProject);
const destinationProject = await createTeamProject('Team Project 2', member);
//
// ACT
//
const response = await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response.body).toEqual({});
const allSharings = await getWorkflowSharing(workflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
});
test.each([
['owners', () => owner],
['admins', () => admin],
])(
'global %s can always transfer from any personal or team project into any team project',
async (_name, actor) => {
test.each<ProjectRole>(['project:editor', 'project:viewer'])(
'%ss cannot transfer workflows',
async (projectRole) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const teamWorkflow = await createWorkflow({}, sourceProject);
const sourceProject = await createTeamProject();
await linkUserToProject(member, sourceProject, projectRole);
const personalWorkflow = await createWorkflow({}, member);
const workflow = await createWorkflow({}, sourceProject);
const destinationProject = await createTeamProject('Destination Project', member);
const destinationProject = await createTeamProject();
await linkUserToProject(member, destinationProject, 'project:admin');
//
// ACT
// ACT & ASSERT
//
const response1 = await testServer
.authAgentFor(actor())
.put(`/workflows/${teamWorkflow.id}/transfer`)
await testServer
.authAgentFor(member)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
const response2 = await testServer
.authAgentFor(actor())
.put(`/workflows/${personalWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ASSERT
//
expect(response1.body).toEqual({});
expect(response2.body).toEqual({});
{
const allSharings = await getWorkflowSharing(teamWorkflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: teamWorkflow.id,
role: 'workflow:owner',
});
}
{
const allSharings = await getWorkflowSharing(personalWorkflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: personalWorkflow.id,
role: 'workflow:owner',
});
}
.expect(403);
},
);
test.each([
['owners', () => owner],
['admins', () => admin],
])('global %s cannot transfer into personal projects', async (_name, actor) => {
//
// ARRANGE
//
const sourceProject = await createTeamProject('Source Project', member);
const teamWorkflow = await createWorkflow({}, sourceProject);
test.each<
[
// user role
'owners' | 'admins',
// source project type
'team' | 'personal',
// destination project type
'team' | 'personal',
// actor
() => User,
// source project
() => Promise<Project> | Project,
// destination project
() => Promise<Project> | Project,
]
>([
// owner
[
'owners',
'team',
'team',
() => owner,
async () => await createTeamProject('Source Project'),
async () => await createTeamProject('Destination Project'),
],
[
'owners',
'team',
'personal',
() => owner,
async () => await createTeamProject('Source Project'),
() => memberPersonalProject,
],
[
'owners',
'personal',
'team',
() => owner,
() => memberPersonalProject,
async () => await createTeamProject('Destination Project'),
],
const personalWorkflow = await createWorkflow({}, member);
// admin
[
'admins',
'team',
'team',
() => admin,
async () => await createTeamProject('Source Project'),
async () => await createTeamProject('Destination Project'),
],
[
'admins',
'team',
'personal',
() => admin,
async () => await createTeamProject('Source Project'),
() => memberPersonalProject,
],
[
'admins',
'personal',
'team',
() => admin,
() => memberPersonalProject,
async () => await createTeamProject('Destination Project'),
],
])(
'global %s can transfer workflows from a %s project to a %s project',
async (
_roleName,
_sourceProjectName,
_destinationProjectName,
getActor,
getSourceProject,
getDestinationProject,
) => {
// ARRANGE
const actor = getActor();
const sourceProject = await getSourceProject();
const destinationProject = await getDestinationProject();
const workflow = await createWorkflow({}, sourceProject);
const destinationProject = anotherMemberPersonalProject;
// ACT
const response = await testServer
.authAgentFor(actor)
.put(`/workflows/${workflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(200);
//
// ACT & ASSERT
//
await testServer
.authAgentFor(actor())
.put(`/workflows/${teamWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
await testServer
.authAgentFor(actor())
.put(`/workflows/${personalWorkflow.id}/transfer`)
.send({ destinationProjectId: destinationProject.id })
.expect(400);
});
// ASSERT
expect(response.body).toEqual({});
const allSharings = await getWorkflowSharing(workflow);
expect(allSharings).toHaveLength(1);
expect(allSharings[0]).toMatchObject({
projectId: destinationProject.id,
workflowId: workflow.id,
role: 'workflow:owner',
});
},
);
test('removes and re-adds the workflow from the active workflow manager during the transfer', async () => {
//

View file

@ -24,7 +24,7 @@ describe('Directive n8n-truncate', () => {
},
},
);
expect(html()).toBe('<div>This is a very long text that...</div>');
expect(html()).toBe('<div>This is a very long text that ...</div>');
});
it('should truncate text to 30 chars in case of wrong argument', async () => {
@ -48,7 +48,7 @@ describe('Directive n8n-truncate', () => {
},
},
);
expect(html()).toBe('<div>This is a very long text that...</div>');
expect(html()).toBe('<div>This is a very long text that ...</div>');
});
it('should truncate text to given length', async () => {
@ -72,6 +72,6 @@ describe('Directive n8n-truncate', () => {
},
},
);
expect(html()).toBe('<div>This is a very long text...</div>');
expect(html()).toBe('<div>This is a very long text ...</div>');
});
});

View file

@ -5,3 +5,4 @@ export * from './typeguards';
export * from './uid';
export * from './valueByPath';
export * from './testUtils';
export * from './string';

View file

@ -4,13 +4,13 @@ describe('Utils string', () => {
describe('truncate', () => {
it('should truncate text to 30 chars by default', () => {
expect(truncate('This is a very long text that should be truncated')).toBe(
'This is a very long text that...',
'This is a very long text that ...',
);
});
it('should truncate text to given length', () => {
expect(truncate('This is a very long text that should be truncated', 25)).toBe(
'This is a very long text...',
'This is a very long text ...',
);
});
});

View file

@ -1,2 +1,2 @@
export const truncate = (text: string, length = 30): string =>
text.length > length ? text.slice(0, length).trim() + '...' : text;
text.length > length ? text.slice(0, length) + '...' : text;

View file

@ -7,6 +7,7 @@ import { isPresent } from '@/utils/typesUtils';
import type { IConnectedNode, Workflow } from 'n8n-workflow';
import { computed } from 'vue';
import NodeIcon from './NodeIcon.vue';
import { truncate } from 'n8n-design-system';
type Props = {
nodes: IConnectedNode[];
@ -100,11 +101,7 @@ function getMultipleNodesText(nodeName: string): string {
}
function title(nodeName: string, length = 30) {
const truncated = nodeName.substring(0, length);
if (truncated.length < nodeName.length) {
return `${truncated}...`;
}
return truncated;
return truncate(nodeName, length);
}
function subtitle(nodeName: string, depth: number) {

View file

@ -31,7 +31,6 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
} from '@/constants';
@ -66,7 +65,6 @@ import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import ProjectMoveResourceConfirmModal from '@/components/Projects/ProjectMoveResourceConfirmModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
</script>
@ -249,15 +247,6 @@ import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
/>
</template>
</ModalRoot>
<ModalRoot :name="PROJECT_MOVE_RESOURCE_CONFIRM_MODAL">
<template #default="{ modalName, data }">
<ProjectMoveResourceConfirmModal
data-test-id="project-move-resource-confirm-modal"
:modal-name="modalName"
:data="data"
/>
</template>
</ModalRoot>
<ModalRoot :name="NEW_ASSISTANT_SESSION_MODAL">
<template #default="{ modalName, data }">
<NewAssistantSessionModal :name="modalName" :data="data" />

View file

@ -1,5 +1,6 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { truncate } from 'n8n-design-system';
const renderComponent = createComponentRenderer(ProjectCardBadge);
@ -56,6 +57,6 @@ describe('ProjectCardBadge', () => {
},
},
});
expect(getByText(result)).toBeVisible();
expect(getByText(truncate(result, 20))).toBeVisible();
});
});

View file

@ -119,7 +119,7 @@ const badgeTooltip = computed(() => {
<template>
<N8nTooltip :disabled="!badgeTooltip" placement="top">
<N8nBadge v-if="badgeText" class="mr-xs" theme="tertiary" bold data-test-id="card-badge">
{{ badgeText }}
<span v-n8n-truncate:20>{{ badgeText }}</span>
<N8nIcon v-if="badgeIcon" :icon="badgeIcon" size="small" class="ml-5xs" />
</N8nBadge>
<template #content>

View file

@ -1,68 +0,0 @@
import { createPinia, setActivePinia } from 'pinia';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
import ProjectMoveResourceConfirmModal from '@/components/Projects/ProjectMoveResourceConfirmModal.vue';
import { useProjectsStore } from '@/stores/projects.store';
import { useTelemetry } from '@/composables/useTelemetry';
vi.mock('@/stores/ui.store', () => ({
useUIStore: vi.fn().mockReturnValue({
modalsById: vi.fn().mockReturnValue(() => {
open: true;
}),
closeModal: vi.fn(),
}),
}));
const renderComponent = createComponentRenderer(ProjectMoveResourceConfirmModal, {
global: {
stubs: {
Modal: {
template:
'<div role="dialog"><slot name="header" /><slot name="content" /><slot name="footer" /></div>',
},
},
},
});
let projectsStore: ReturnType<typeof useProjectsStore>;
let telemetry: ReturnType<typeof useTelemetry>;
describe('ProjectMoveResourceConfirmModal', () => {
beforeEach(() => {
setActivePinia(createPinia());
projectsStore = useProjectsStore();
telemetry = useTelemetry();
});
it('should send telemetry when resource moving is confirmed', async () => {
vi.spyOn(projectsStore, 'moveResourceToProject').mockResolvedValue();
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
const props = {
modalName: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
data: {
resourceType: 'workflow',
resource: {
id: '1',
},
projectId: '1',
projectName: 'My Project',
},
};
const { getByRole, getAllByRole } = renderComponent({ props });
const confirmBtn = getByRole('button', { name: /confirm/i });
expect(confirmBtn).toBeDisabled();
await Promise.all(
getAllByRole('checkbox').map(async (checkbox) => await userEvent.click(checkbox)),
);
expect(confirmBtn).toBeEnabled();
await userEvent.click(confirmBtn);
expect(telemetryTrackSpy).toHaveBeenCalledWith(
'User successfully moved workflow',
expect.objectContaining({ workflow_id: '1' }),
);
});
});

View file

@ -1,131 +0,0 @@
<script lang="ts" setup>
import { ref, computed, h } from 'vue';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue';
import { N8nCheckbox, N8nText } from 'n8n-design-system';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import { VIEWS } from '@/constants';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import { ResourceType } from '@/utils/projects.utils';
const props = defineProps<{
modalName: string;
data: {
resource: IWorkflowDb | ICredentialsResponse;
resourceType: ResourceType;
resourceTypeLabel: string;
projectId: string;
projectName: string;
};
}>();
const i18n = useI18n();
const toast = useToast();
const uiStore = useUIStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const checks = ref([false, false]);
const allChecked = computed(() => checks.value.every(Boolean));
const moveResourceLabel = computed(() =>
props.data.resourceType === ResourceType.Workflow
? i18n.baseText('projects.move.workflow.confirm.modal.label')
: i18n.baseText('projects.move.credential.confirm.modal.label'),
);
const closeModal = () => {
uiStore.closeModal(props.modalName);
};
const confirm = async () => {
try {
await projectsStore.moveResourceToProject(
props.data.resourceType,
props.data.resource.id,
props.data.projectId,
);
closeModal();
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
toast.showToast({
title: i18n.baseText('projects.move.resource.success.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
},
}),
message: h(ProjectMoveSuccessToastMessage, {
routeName:
props.data.resourceType === ResourceType.Workflow
? VIEWS.PROJECTS_WORKFLOWS
: VIEWS.PROJECTS_CREDENTIALS,
resource: props.data.resource,
resourceTypeLabel: props.data.resourceTypeLabel,
projectId: props.data.projectId,
projectName: props.data.projectName,
}),
type: 'success',
});
} catch (error) {
toast.showError(
error.message,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: props.data.resource.name,
},
}),
);
}
};
</script>
<template>
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-confirm-modal">
<template #header>
<N8nHeading tag="h2" size="xlarge" class="mb-m">
{{ i18n.baseText('projects.move.resource.confirm.modal.title') }}
</N8nHeading>
</template>
<template #content>
<N8nCheckbox v-model="checks[0]" :label="moveResourceLabel" />
<N8nCheckbox v-model="checks[1]">
<N8nText>
<i18n-t keypath="projects.move.resource.confirm.modal.label">
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
<template #numberOfUsers>{{
i18n.baseText('projects.move.resource.confirm.modal.numberOfUsers', {
interpolate: {
numberOfUsers: props.data.resource.sharedWithProjects?.length ?? 0,
},
adjustToNumber: props.data.resource.sharedWithProjects?.length,
})
}}</template>
</i18n-t>
</N8nText>
</N8nCheckbox>
</template>
<template #footer>
<div :class="$style.buttons">
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton :disabled="!allChecked" type="primary" @click="confirm">
{{ i18n.baseText('projects.move.resource.confirm.modal.button.confirm') }}
</N8nButton>
</div>
</template>
</Modal>
</template>
<style lang="scss" module>
.buttons {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -1,8 +1,10 @@
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
import { PROJECT_MOVE_RESOURCE_MODAL } from '@/constants';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/stores/projects.store';
const renderComponent = createComponentRenderer(ProjectMoveResourceModal, {
global: {
@ -19,28 +21,67 @@ let telemetry: ReturnType<typeof useTelemetry>;
describe('ProjectMoveResourceModal', () => {
beforeEach(() => {
setActivePinia(createPinia());
telemetry = useTelemetry();
});
it('should send telemetry when mounted', async () => {
const pinia = createTestingPinia();
const telemetryTrackSpy = vi.spyOn(telemetry, 'track');
const projectsStore = mockedStore(useProjectsStore);
projectsStore.availableProjects = [
{
id: '1',
name: 'My Project',
type: 'personal',
role: 'project:personalOwner',
createdAt: '2021-01-01T00:00:00.000Z',
updatedAt: '2021-01-01T00:00:00.000Z',
},
];
const props = {
modalName: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'Workflow',
resource: {
id: '1',
homeProject: {
id: '2',
name: 'My Project',
},
},
projectId: '1',
projectName: 'My Project',
},
};
renderComponent({ props });
renderComponent({ props, pinia });
expect(telemetryTrackSpy).toHaveBeenCalledWith(
'User clicked to move a workflow',
expect.objectContaining({ workflow_id: '1' }),
);
});
it('should show no available projects message', async () => {
const pinia = createTestingPinia();
const projectsStore = mockedStore(useProjectsStore);
projectsStore.availableProjects = [];
const props = {
modalName: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resourceType: 'workflow',
resourceTypeLabel: 'Workflow',
resource: {
id: '1',
homeProject: {
id: '2',
name: 'My Project',
},
},
},
};
const { getByText } = renderComponent({ props, pinia });
expect(getByText(/Currently there are not any projects or users available/)).toBeVisible();
});
});

View file

@ -1,14 +1,17 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, h } from 'vue';
import type { ICredentialsResponse, IWorkflowDb } from '@/Interface';
import { useI18n } from '@/composables/useI18n';
import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue';
import { PROJECT_MOVE_RESOURCE_CONFIRM_MODAL } from '@/constants';
import type { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import { VIEWS } from '@/constants';
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 { useToast } from '@/composables/useToast';
import { getResourcePermissions } from '@/permissions';
const props = defineProps<{
modalName: string;
@ -21,17 +24,37 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
const toast = useToast();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const filter = ref('');
const projectId = ref<string | null>(null);
const processedName = computed(() => {
const { name, email } = splitName(props.data.resource.homeProject?.name ?? '');
const processedName = computed(
() => processProjectName(props.data.resource.homeProject?.name ?? '') ?? '',
);
const availableProjects = computed(() =>
projectsStore.availableProjects
.filter(
(p) =>
p.name?.toLowerCase().includes(filter.value.toLowerCase()) &&
p.id !== props.data.resource.homeProject?.id &&
(!p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create),
)
.sort((a, b) => a.name?.localeCompare(b.name ?? '') ?? 0),
);
const selectedProject = computed(() =>
availableProjects.value.find((p) => p.id === projectId.value),
);
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
resource.homeProject?.type === ProjectTypes.Team;
const processProjectName = (projectName: string) => {
const { name, email } = splitName(projectName);
return name ?? email;
});
const availableProjects = computed(() => {
return projectsStore.teamProjects.filter((p) => p.id !== props.data.resource.homeProject?.id);
});
};
const updateProject = (value: string) => {
projectId.value = value;
@ -41,18 +64,53 @@ const closeModal = () => {
uiStore.closeModal(props.modalName);
};
const next = () => {
closeModal();
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
data: {
resource: props.data.resource,
resourceType: props.data.resourceType,
resourceTypeLabel: props.data.resourceTypeLabel,
projectId: projectId.value,
projectName: availableProjects.value.find((p) => p.id === projectId.value)?.name ?? '',
},
});
const setFilter = (query: string) => {
filter.value = query;
};
const moveResource = async () => {
if (!selectedProject.value) return;
try {
await projectsStore.moveResourceToProject(
props.data.resourceType,
props.data.resource.id,
selectedProject.value.id,
);
closeModal();
telemetry.track(`User successfully moved ${props.data.resourceType}`, {
[`${props.data.resourceType}_id`]: props.data.resource.id,
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
});
toast.showToast({
title: i18n.baseText('projects.move.resource.success.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
},
}),
message: h(ProjectMoveSuccessToastMessage, {
routeName:
props.data.resourceType === ResourceType.Workflow
? VIEWS.PROJECTS_WORKFLOWS
: VIEWS.PROJECTS_CREDENTIALS,
resource: props.data.resource,
resourceType: props.data.resourceType,
resourceTypeLabel: props.data.resourceTypeLabel,
targetProject: selectedProject.value,
}),
type: 'success',
duration: 8000,
});
} catch (error) {
toast.showError(
error.message,
i18n.baseText('projects.move.resource.error.title', {
interpolate: {
resourceTypeLabel: props.data.resourceTypeLabel,
resourceName: props.data.resource.name,
},
}),
);
}
};
onMounted(() => {
@ -65,7 +123,7 @@ onMounted(() => {
<template>
<Modal width="500px" :name="props.modalName" data-test-id="project-move-resource-modal">
<template #header>
<N8nHeading tag="h2" size="xlarge" class="mb-m">
<N8nHeading tag="h2" size="xlarge" class="mb-m pr-s">
{{
i18n.baseText('projects.move.resource.modal.title', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
@ -77,20 +135,37 @@ onMounted(() => {
<template #resourceName
><strong>{{ props.data.resource.name }}</strong></template
>
<template #resourceHomeProjectName>{{ processedName }}</template>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
<template v-if="isResourceInTeamProject" #inTeamProject>
<i18n-t keypath="projects.move.resource.modal.message.team">
<template #resourceHomeProjectName
><strong>{{ processedName }}</strong></template
>
</i18n-t>
</template>
<template v-else #inPersonalProject>
<i18n-t keypath="projects.move.resource.modal.message.personal">
<template #resourceHomeProjectName
><strong>{{ processedName }}</strong></template
>
</i18n-t>
</template>
</i18n-t>
</N8nText>
</template>
<template #content>
<div>
<div v-if="availableProjects.length">
<N8nSelect
class="mr-2xs"
class="mr-2xs mb-xs"
:model-value="projectId"
size="small"
:filterable="true"
:filter-method="setFilter"
:placeholder="i18n.baseText('projects.move.resource.modal.selectPlaceholder')"
data-test-id="project-move-resource-modal-select"
@update:model-value="updateProject"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
<N8nOption
v-for="p in availableProjects"
:key="p.id"
@ -98,15 +173,45 @@ onMounted(() => {
:label="p.name"
></N8nOption>
</N8nSelect>
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
<template #note
><strong>{{
i18n.baseText('projects.move.resource.modal.message.note')
}}</strong></template
>
<template #resourceTypeLabel>{{ props.data.resourceTypeLabel }}</template>
</i18n-t>
<span v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0">
<br />
{{
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
adjustToNumber: props.data.resource.sharedWithProjects?.length,
interpolate: {
numberOfProjects: props.data.resource.sharedWithProjects?.length ?? 0,
},
})
}}</span
>
</N8nText>
</div>
<N8nText v-else>{{
i18n.baseText('projects.move.resource.modal.message.noProjects', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}</N8nText>
</template>
<template #footer>
<div :class="$style.buttons">
<N8nButton type="secondary" text class="mr-2xs" @click="closeModal">
{{ i18n.baseText('generic.cancel') }}
</N8nButton>
<N8nButton :disabled="!projectId" type="primary" @click="next">
{{ i18n.baseText('generic.next') }}
<N8nButton :disabled="!projectId" type="primary" @click="moveResource">
{{
i18n.baseText('projects.move.resource.modal.button', {
interpolate: { resourceTypeLabel: props.data.resourceTypeLabel },
})
}}
</N8nButton>
</div>
</template>

View file

@ -0,0 +1,87 @@
import { createComponentRenderer } from '@/__tests__/render';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import { ResourceType } from '@/utils/projects.utils';
import { VIEWS } from '@/constants';
import { ProjectTypes } from '@/types/projects.types';
const renderComponent = createComponentRenderer(ProjectMoveSuccessToastMessage, {
global: {
stubs: {
'router-link': {
template: '<a href="#"><slot /></a>',
},
},
},
});
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',
},
};
const { getByText } = renderComponent({ props });
expect(getByText(/Please double check any credentials/)).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,
},
};
const { getByRole } = renderComponent({ props });
expect(getByRole('link')).toBeInTheDocument();
});
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,
},
};
const { getByText, queryByText, queryByRole } = renderComponent({ props });
expect(getByText(/credential was moved to /)).toBeInTheDocument();
expect(queryByText(/Please double check any credentials/)).not.toBeInTheDocument();
expect(queryByRole('link')).not.toBeInTheDocument();
});
});

View file

@ -1,32 +1,57 @@
<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';
const props = defineProps<{
routeName: string;
resource: IWorkflowDb | ICredentialsResponse;
resourceType: ResourceType;
resourceTypeLabel: string;
projectId: string;
projectName: string;
targetProject: ProjectListItem;
}>();
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
const projectName = 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>{{ props.resource.name }}</template>
<template #targetProjectName>{{ props.projectName }}</template>
<template #link>
<router-link
:to="{
name: props.routeName,
params: { projectId: props.projectId },
}"
>
<p class="pt-s">
<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>{{ props.projectName }}</template>
<template #targetProjectName>{{ projectName }}</template>
</i18n-t>
</p>
</router-link>
</router-link>
</p>
</template>
</i18n-t>
</template>

View file

@ -67,5 +67,9 @@ const processedName = computed(() => {
.text {
display: flex;
flex-direction: column;
p {
margin: 0;
}
}
</style>

View file

@ -63,10 +63,11 @@ export default defineComponent({
},
},
async beforeMount() {
await this.projectsStore.getAllProjects();
await this.projectsStore.getAvailableProjects();
this.selectedProject =
this.projectsStore.projects.find((project) => project.id === this.modelValue.homeProject) ??
null;
this.projectsStore.availableProjects.find(
(project) => project.id === this.modelValue.homeProject,
) ?? null;
},
methods: {
setKeyValue(key: string, value: unknown) {
@ -126,7 +127,7 @@ export default defineComponent({
/>
<ProjectSharing
v-model="selectedProject"
:projects="projectsStore.projects"
:projects="projectsStore.availableProjects"
:placeholder="$locale.baseText('forms.resourceFiltersDropdown.owner.placeholder')"
:empty-options-text="$locale.baseText('projects.sharing.noMatchingProjects')"
@update:model-value="setKeyValue('homeProject', ($event as ProjectSharingData).id)"

View file

@ -69,7 +69,6 @@ export const PROMPT_MFA_CODE_MODAL_KEY = 'promptMfaCode';
export const WORKFLOW_HISTORY_VERSION_RESTORE = 'workflowHistoryVersionRestore';
export const SETUP_CREDENTIALS_MODAL_KEY = 'setupCredentials';
export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
export const PROJECT_MOVE_RESOURCE_CONFIRM_MODAL = 'projectMoveResourceConfirmModal';
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';

View file

@ -2516,17 +2516,20 @@
"projects.create.limit": "{num} project | {num} projects",
"projects.create.limitReached": "You have reached the {planName} plan limit of {limit}. Upgrade your plan to unlock more projects. {link}",
"projects.create.limitReached.link": "View plans",
"projects.move.resource.modal.title": "Choose a project to move this {resourceTypeLabel} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently in the \"{resourceHomeProjectName}\" project. Which project would you like to move this {resourceTypeLabel} to?",
"projects.move.resource.confirm.modal.title": "Please confirm the following",
"projects.move.resource.confirm.modal.button.confirm": "Confirm move to new project",
"projects.move.workflow.confirm.modal.label": "This workflow may stop working if the credentials used with it do not exist in the project its being moved to",
"projects.move.credential.confirm.modal.label": "Any workflows currently using this credential will stop working once this credential has been moved",
"projects.move.resource.confirm.modal.label": "Any individual sharing currently associated with this {resourceTypeLabel} will be removed. (currently shared with {numberOfUsers})",
"projects.move.resource.confirm.modal.numberOfUsers": "{numberOfUsers} user | {numberOfUsers} users",
"projects.move.resource.modal.title": "Choose a project or user to move this {resourceTypeLabel} to",
"projects.move.resource.modal.message": "\"{resourceName}\" is currently {inPersonalProject}{inTeamProject}",
"projects.move.resource.modal.message.team": "in the \"{resourceHomeProjectName}\" project.",
"projects.move.resource.modal.message.personal": "owned by \"{resourceHomeProjectName}\".",
"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.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} {link}",
"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.link": "View {targetProjectName}",
"projects.badge.tooltip.sharedOwned": "This {resourceTypeLabel} is owned by you and shared with one or more projects or users",
"projects.badge.tooltip.sharedPersonal": "This {resourceTypeLabel} is owned by {name} and shared with one or more projects or users",

View file

@ -19,6 +19,8 @@ import type { IWorkflowDb } from '@/Interface';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store';
import { STORES } from '@/constants';
import { useUsersStore } from '@/stores/users.store';
import { getResourcePermissions } from '@/permissions';
export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const route = useRoute();
@ -26,6 +28,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const settingsStore = useSettingsStore();
const workflowsStore = useWorkflowsStore();
const credentialsStore = useCredentialsStore();
const usersStore = useUsersStore();
const projects = ref<ProjectListItem[]>([]);
const myProjects = ref<ProjectListItem[]>([]);
@ -38,6 +41,13 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
});
const projectNavActiveIdState = ref<string | string[] | null>(null);
const globalProjectPermissions = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).project,
);
const availableProjects = computed(() =>
globalProjectPermissions.value.list ? projects.value : myProjects.value,
);
const currentProjectId = computed(
() =>
(route.params?.projectId as string | undefined) ??
@ -91,6 +101,14 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
personalProject.value = await projectsApi.getPersonalProject(rootStore.restApiContext);
};
const getAvailableProjects = async () => {
if (globalProjectPermissions.value.list) {
await getAllProjects();
} else {
await getMyProjects();
}
};
const fetchProject = async (id: string) =>
await projectsApi.getProject(rootStore.restApiContext, id);
@ -189,6 +207,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
return {
projects,
availableProjects,
myProjects,
personalProject,
currentProject,
@ -206,6 +225,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
getAllProjects,
getMyProjects,
getPersonalProject,
getAvailableProjects,
fetchProject,
getProject,
createProject,

View file

@ -35,7 +35,6 @@ import {
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
} from '@/constants';
@ -126,7 +125,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROJECT_MOVE_RESOURCE_CONFIRM_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
].map((modalKey) => [modalKey, { open: false }]),
),

View file

@ -51,10 +51,8 @@ describe('ProjectSettings', () => {
settingsStore = useSettingsStore();
vi.spyOn(usersStore, 'fetchUsers').mockImplementation(async () => await Promise.resolve());
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(
async () => await Promise.resolve(),
);
vi.spyOn(projectsStore, 'projects', 'get').mockReturnValue(projects);
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
vi.spyOn(projectsStore, 'availableProjects', 'get').mockReturnValue(projects);
vi.spyOn(settingsStore, 'settings', 'get').mockReturnValue({
enterprise: {
projects: {

View file

@ -60,7 +60,9 @@ const usersList = computed(() =>
);
const projects = computed(() =>
projectsStore.projects.filter((project) => project.id !== projectsStore.currentProjectId),
projectsStore.availableProjects.filter(
(project) => project.id !== projectsStore.currentProjectId,
),
);
const projectRoles = computed(() =>
rolesStore.processedProjectRoles.map((role) => ({
@ -200,7 +202,7 @@ const onSubmit = async () => {
};
const onDelete = async () => {
await projectsStore.getAllProjects();
await projectsStore.getAvailableProjects();
dialogVisible.value = true;
};

View file

@ -61,7 +61,7 @@ describe('WorkflowsView', () => {
usersStore = useUsersStore();
projectsStore = useProjectsStore();
vi.spyOn(projectsStore, 'getAllProjects').mockImplementation(async () => {});
vi.spyOn(projectsStore, 'getAvailableProjects').mockImplementation(async () => {});
await settingsStore.getSettings();
await usersStore.fetchUsers();

View file

@ -258,7 +258,7 @@ const WorkflowsView = defineComponent({
});
},
isValidProjectId(projectId: string) {
return this.projectsStore.projects.some((project) => project.id === projectId);
return this.projectsStore.availableProjects.some((project) => project.id === projectId);
},
async setFiltersFromQueryString() {
const { tags, status, search, homeProject } = this.$route.query;
@ -266,7 +266,7 @@ const WorkflowsView = defineComponent({
const filtersToApply: { [key: string]: string | string[] | boolean } = {};
if (homeProject && typeof homeProject === 'string') {
await this.projectsStore.getAllProjects();
await this.projectsStore.getAvailableProjects();
if (this.isValidProjectId(homeProject)) {
filtersToApply.homeProject = homeProject;
}