diff --git a/package.json b/package.json index d990b26e7d2..a8056ef5420 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,8 @@ "js-base64": "patches/js-base64.patch", "ics": "patches/ics.patch", "minifaker": "patches/minifaker.patch", - "z-vue-scan": "patches/z-vue-scan.patch" + "z-vue-scan": "patches/z-vue-scan.patch", + "v-code-diff": "patches/v-code-diff.patch" } } } diff --git a/packages/cli/src/controllers/folder.controller.ts b/packages/cli/src/controllers/folder.controller.ts index 0a583c3eae0..51746e52d35 100644 --- a/packages/cli/src/controllers/folder.controller.ts +++ b/packages/cli/src/controllers/folder.controller.ts @@ -17,6 +17,7 @@ import { Query, Put, Param, + Licensed, } from '@n8n/decorators'; import { Response } from 'express'; import { UserError } from 'n8n-workflow'; @@ -37,6 +38,7 @@ export class ProjectController { @Post('/') @ProjectScope('folder:create') + @Licensed('feat:folders') async createFolder( req: AuthenticatedRequest<{ projectId: string }>, _res: Response, @@ -55,6 +57,7 @@ export class ProjectController { @Get('/:folderId/tree') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderTree( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -74,6 +77,7 @@ export class ProjectController { @Get('/:folderId/credentials') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderUsedCredentials( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -97,6 +101,7 @@ export class ProjectController { @Patch('/:folderId') @ProjectScope('folder:update') + @Licensed('feat:folders') async updateFolder( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -118,6 +123,7 @@ export class ProjectController { @Delete('/:folderId') @ProjectScope('folder:delete') + @Licensed('feat:folders') async deleteFolder( req: AuthenticatedRequest<{ projectId: string; folderId: string }>, _res: Response, @@ -139,6 +145,7 @@ export class ProjectController { @Get('/') @ProjectScope('folder:list') + @Licensed('feat:folders') async listFolders( req: AuthenticatedRequest<{ projectId: string }>, res: Response, @@ -153,6 +160,7 @@ export class ProjectController { @Get('/:folderId/content') @ProjectScope('folder:read') + @Licensed('feat:folders') async getFolderContent(req: AuthenticatedRequest<{ projectId: string; folderId: string }>) { const { projectId, folderId } = req.params; @@ -174,6 +182,7 @@ export class ProjectController { @Put('/:folderId/transfer') @ProjectScope('folder:move') + @Licensed('feat:folders') async transferFolderToProject( req: AuthenticatedRequest, _res: unknown, diff --git a/packages/cli/test/integration/folder/folder.controller.test.ts b/packages/cli/test/integration/folder/folder.controller.test.ts index 18ca545cba2..1889d432b43 100644 --- a/packages/cli/test/integration/folder/folder.controller.test.ts +++ b/packages/cli/test/integration/folder/folder.controller.test.ts @@ -49,6 +49,8 @@ let workflowRepository: WorkflowRepository; const activeWorkflowManager = mockInstance(ActiveWorkflowManager); beforeEach(async () => { + testServer.license.enable('feat:folders'); + await testDb.truncate(['Folder', 'SharedWorkflow', 'TagEntity', 'Project', 'ProjectRelation']); projectRepository = Container.get(ProjectRepository); @@ -66,6 +68,18 @@ beforeEach(async () => { }); describe('POST /projects/:projectId/folders', () => { + test('should now create folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + const project = await createTeamProject(undefined, owner); + await linkUserToProject(member, project, 'project:viewer'); + + const payload = { + name: 'Test Folder', + }; + + await authMemberAgent.post(`/projects/${project.id}/folders`).send(payload).expect(403); + }); + test('should not create folder when project does not exist', async () => { const payload = { name: 'Test Folder', @@ -235,6 +249,32 @@ describe('POST /projects/:projectId/folders', () => { }); describe('GET /projects/:projectId/folders/:folderId/tree', () => { + test('should not retrieve folder tree if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + await authOwnerAgent + .get(`/projects/${project.id}/folders/${grandchildFolder.id}/tree`) + .expect(403); + }); + test('should not get folder tree when project does not exist', async () => { await authOwnerAgent.get('/projects/non-existing-id/folders/some-folder-id/tree').expect(403); }); @@ -311,6 +351,68 @@ describe('GET /projects/:projectId/folders/:folderId/tree', () => { }); describe('GET /projects/:projectId/folders/:folderId/credentials', () => { + test('should not retrieve folder tree if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test', owner); + const rootFolder = await createFolder(project, { name: 'Root' }); + + const childFolder1 = await createFolder(project, { + name: 'Child 1', + parentFolder: rootFolder, + }); + + await createFolder(project, { + name: 'Child 2', + parentFolder: rootFolder, + }); + + const grandchildFolder = await createFolder(project, { + name: 'Grandchild', + parentFolder: childFolder1, + }); + + for (const folder of [rootFolder, childFolder1, grandchildFolder]) { + const credential = await createCredentials( + { + name: `Test credential ${folder.name}`, + data: '', + type: 'test', + }, + project, + ); + + await createWorkflow( + { + name: 'Test Workflow', + parentFolder: folder, + active: false, + nodes: [ + { + parameters: {}, + type: '@n8n/n8n-nodes-langchain.lmChatOpenAi', + typeVersion: 1.2, + position: [0, 0], + id: faker.string.uuid(), + name: 'OpenAI Chat Model', + credentials: { + openAiApi: { + id: credential.id, + name: credential.name, + }, + }, + }, + ], + }, + owner, + ); + } + + await authOwnerAgent + .get(`/projects/${project.id}/folders/${childFolder1.id}/credentials`) + .expect(403); + }); + test('should not get folder credentials when project does not exist', async () => { await authOwnerAgent .get('/projects/non-existing-id/folders/some-folder-id/credentials') @@ -416,6 +518,23 @@ describe('GET /projects/:projectId/folders/:folderId/credentials', () => { }); describe('PATCH /projects/:projectId/folders/:folderId', () => { + test('should not update folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project, { name: 'Original Name' }); + await linkUserToProject(member, project, 'project:editor'); + + const payload = { + name: 'Updated Folder Name', + }; + + await authMemberAgent + .patch(`/projects/${project.id}/folders/${folder.id}`) + .send(payload) + .expect(403); + }); + test('should not update folder when project does not exist', async () => { const payload = { name: 'Updated Folder Name', @@ -868,6 +987,18 @@ describe('PATCH /projects/:projectId/folders/:folderId', () => { }); describe('DELETE /projects/:projectId/folders/:folderId', () => { + test('should not delete folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject(undefined, owner); + const folder = await createFolder(project); + + await authOwnerAgent + .delete(`/projects/${project.id}/folders/${folder.id}`) + .send({}) + .expect(403); + }); + test('should not delete folder when project does not exist', async () => { await authOwnerAgent .delete('/projects/non-existing-id/folders/some-folder-id') @@ -1159,6 +1290,16 @@ describe('DELETE /projects/:projectId/folders/:folderId', () => { }); describe('GET /projects/:projectId/folders', () => { + test('should not retrieve folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + await createFolder(project, { name: 'Test Folder' }); + + await authMemberAgent.get(`/projects/${project.id}/folders`).expect(403); + }); + test('should not list folders when project does not exist', async () => { await authOwnerAgent.get('/projects/non-existing-id/folders').expect(403); }); @@ -1570,6 +1711,16 @@ describe('GET /projects/:projectId/folders', () => { }); describe('GET /projects/:projectId/folders/content', () => { + test('should not retrieve folder content if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const project = await createTeamProject('test project', owner); + await linkUserToProject(member, project, 'project:viewer'); + const folder = await createFolder(project, { name: 'Test Folder' }); + + await authMemberAgent.get(`/projects/${project.id}/folders/${folder.id}/content`).expect(403); + }); + test('should not list folders when project does not exist', async () => { await authOwnerAgent .get('/projects/non-existing-id/folders/no-existing-id/content') @@ -1634,6 +1785,31 @@ describe('GET /projects/:projectId/folders/content', () => { }); describe('PUT /projects/:projectId/folders/:folderId/transfer', () => { + test('should not transfer folder if license does not allow it', async () => { + testServer.license.disable('feat:folders'); + + const admin = await createUser({ role: 'global:admin' }); + const sourceProject = await createTeamProject('source project', admin); + const destinationProject = await createTeamProject('destination project', member); + const sourceFolder1 = await createFolder(sourceProject, { name: 'Source Folder 1' }); + + const credential = await saveCredential(randomCredentialPayload(), { + project: sourceProject, + role: 'credential:owner', + }); + + // ACT + await testServer + .authAgentFor(owner) + .put(`/projects/${sourceProject.id}/folders/${sourceFolder1.id}/transfer`) + .send({ + destinationProjectId: destinationProject.id, + destinationParentFolderId: '0', + shareCredentials: [credential.id], + }) + .expect(403); + }); + test('cannot transfer into the same project', async () => { const sourceProject = await createTeamProject('source project', member); const destinationProject = await createTeamProject('Team Project', member); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts index 674963f4a06..5b543605418 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nIcon/icons.ts @@ -78,6 +78,7 @@ import IconLucideEyeOff from '~icons/lucide/eye-off'; import IconLucideFile from '~icons/lucide/file'; import IconLucideFileArchive from '~icons/lucide/file-archive'; import IconLucideFileCode from '~icons/lucide/file-code'; +import IconLucideFileDiff from '~icons/lucide/file-diff'; import IconLucideFileDown from '~icons/lucide/file-down'; import IconLucideFileInput from '~icons/lucide/file-input'; import IconLucideFileOutput from '~icons/lucide/file-output'; @@ -474,6 +475,7 @@ export const updatedIconSet = { file: IconLucideFile, 'file-archive': IconLucideFileArchive, 'file-code': IconLucideFileCode, + 'file-diff': IconLucideFileDiff, 'file-down': IconLucideFileDown, 'file-input': IconLucideFileInput, 'file-output': IconLucideFileOutput, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nRadioButtons/RadioButtons.vue b/packages/frontend/@n8n/design-system/src/components/N8nRadioButtons/RadioButtons.vue index 2f7ac722256..65cc09194f5 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nRadioButtons/RadioButtons.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nRadioButtons/RadioButtons.vue @@ -5,6 +5,7 @@ interface RadioOption { label: string; value: Value; disabled?: boolean; + data?: Record; } interface RadioButtonsProps { diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index c1b8acec67b..ff12b568ed3 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -2560,6 +2560,8 @@ "workflowSettings.showMessage.saveSettings.title": "Workflow settings saved", "workflowSettings.timeoutAfter": "Timeout After", "workflowSettings.timeoutWorkflow": "Timeout Workflow", + "workflowSettings.executionTimeout": "Timeout Workflow", + "workflowSettings.tags": "Tags", "workflowSettings.timezone": "Timezone", "workflowSettings.timeSavedPerExecution": "Estimated time saved", "workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution", diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index bb47a885b5b..2c843a2f66d 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -85,6 +85,7 @@ "timeago.js": "^4.0.2", "typescript": "catalog:", "uuid": "catalog:", + "v-code-diff": "^1.13.1", "v3-infinite-loading": "^1.2.2", "vue": "catalog:frontend", "vue-agile": "^2.0.0", diff --git a/packages/frontend/editor-ui/src/api/sourceControl.ts b/packages/frontend/editor-ui/src/api/sourceControl.ts index f372d766ac3..eec1c7a94cf 100644 --- a/packages/frontend/editor-ui/src/api/sourceControl.ts +++ b/packages/frontend/editor-ui/src/api/sourceControl.ts @@ -9,6 +9,7 @@ import type { SourceControlStatus, SshKeyTypes, } from '@/types/sourceControl.types'; +import type { IWorkflowDb } from '@/Interface'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { TupleToUnion } from '@/utils/typeHelpers'; @@ -56,6 +57,17 @@ export const getStatus = async (context: IRestApiContext): Promise => { + return await makeRestApiRequest( + context, + 'GET', + `${sourceControlApiRoot}/remote-content/workflow/${workflowId}`, + ); +}; + export const getAggregatedStatus = async ( context: IRestApiContext, options: { diff --git a/packages/frontend/editor-ui/src/components/Modal.vue b/packages/frontend/editor-ui/src/components/Modal.vue index 7ca6d6dce9d..08b6a91371b 100644 --- a/packages/frontend/editor-ui/src/components/Modal.vue +++ b/packages/frontend/editor-ui/src/components/Modal.vue @@ -160,7 +160,7 @@ function getCustomClass() { @opened="onOpened" >