diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 4c0ca64291ce..3b4088545736 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -79,6 +79,8 @@ export { TransferWorkflowBodyDto } from './workflows/transfer.dto'; export { ActivateWorkflowDto } from './workflows/activate-workflow.dto'; export { DeactivateWorkflowDto } from './workflows/deactivate-workflow.dto'; export { ArchiveWorkflowDto } from './workflows/archive-workflow.dto'; +export { GetResourceDependencyCountsDto } from './workflows/get-resource-dependency-counts.dto'; +export { GetResourceDependenciesDto } from './workflows/get-resource-dependencies.dto'; export { CreateOrUpdateTagRequestDto } from './tag/create-or-update-tag-request.dto'; export { RetrieveTagQueryDto } from './tag/retrieve-tag-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/workflows/get-resource-dependencies.dto.ts b/packages/@n8n/api-types/src/dto/workflows/get-resource-dependencies.dto.ts new file mode 100644 index 000000000000..f99aac0ac588 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/get-resource-dependencies.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class GetResourceDependenciesDto extends Z.class({ + resourceIds: z.array(z.string()).min(1).max(100), + resourceType: z.enum(['workflow', 'credential', 'dataTable']), +}) {} diff --git a/packages/@n8n/api-types/src/dto/workflows/get-resource-dependency-counts.dto.ts b/packages/@n8n/api-types/src/dto/workflows/get-resource-dependency-counts.dto.ts new file mode 100644 index 000000000000..74308eeeb51d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/workflows/get-resource-dependency-counts.dto.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class GetResourceDependencyCountsDto extends Z.class({ + resourceIds: z.array(z.string()).min(1).max(100), + resourceType: z.enum(['workflow', 'credential', 'dataTable']), +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index ba085686c586..7bce79b37811 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -120,6 +120,18 @@ export { WORKFLOW_VERSION_NAME_MAX_LENGTH, WORKFLOW_VERSION_DESCRIPTION_MAX_LENGTH, } from './schemas/workflow-version.schema'; +export type { + DependencyType, + DependencyResourceType, + ResolvedDependency, + ResolvedDependenciesResult, + DependenciesBatchResponse, +} from './schemas/dependency.schema'; + +export type { + DependencyTypeCounts, + DependencyCountsBatchResponse, +} from './schemas/dependency-counts.schema'; export type { ProjectType, diff --git a/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts b/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts new file mode 100644 index 000000000000..71e61b7b23e6 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/dependency-counts.schema.ts @@ -0,0 +1,12 @@ +export interface DependencyTypeCounts { + credentialId: number; + dataTableId: number; + workflowCall: number; + workflowParent: number; +} + +/** + * Lightweight response for workflow cards — only the counts per type, + * no resolved names / project IDs. Keyed by resource ID. + */ +export type DependencyCountsBatchResponse = Record; diff --git a/packages/@n8n/api-types/src/schemas/dependency.schema.ts b/packages/@n8n/api-types/src/schemas/dependency.schema.ts new file mode 100644 index 000000000000..747fcf2d0e99 --- /dev/null +++ b/packages/@n8n/api-types/src/schemas/dependency.schema.ts @@ -0,0 +1,19 @@ +export type DependencyType = 'credentialId' | 'dataTableId' | 'workflowCall' | 'workflowParent'; + +export type DependencyResourceType = 'workflow' | 'credential' | 'dataTable'; + +export interface ResolvedDependency { + type: DependencyType; + id: string; + name: string; + /** Project ID — included for data tables so the frontend can build a direct link */ + projectId?: string; +} + +export interface ResolvedDependenciesResult { + dependencies: ResolvedDependency[]; + /** Number of dependencies the user does not have access to view */ + inaccessibleCount: number; +} + +export type DependenciesBatchResponse = Record; diff --git a/packages/cli/src/credentials/credentials-finder.service.ts b/packages/cli/src/credentials/credentials-finder.service.ts index 060b385cd971..f88769248412 100644 --- a/packages/cli/src/credentials/credentials-finder.service.ts +++ b/packages/cli/src/credentials/credentials-finder.service.ts @@ -224,6 +224,54 @@ export class CredentialsFinderService { return sharedCredentialsList; } + /** + * Given a list of credential IDs, return only those the user can access with the given scopes. + */ + async findCredentialIdsWithScopeForUser( + credentialIds: string[], + user: User, + scopes: Scope[], + ): Promise> { + if (credentialIds.length === 0) return new Set(); + + let where: FindOptionsWhere = { credentialsId: In(credentialIds) }; + + if (!hasGlobalScope(user, scopes, { mode: 'allOf' })) { + const [projectRoles, credentialRoles] = await Promise.all([ + this.roleService.rolesWithScope('project', scopes), + this.roleService.rolesWithScope('credential', scopes), + ]); + where = { + ...where, + role: In(credentialRoles), + project: { + projectRelations: { + role: In(projectRoles), + userId: user.id, + }, + }, + }; + } + + const sharedCredentials = await this.sharedCredentialsRepository.find({ + select: { credentialsId: true }, + where, + }); + + const result = new Set(sharedCredentials.map((sc) => sc.credentialsId)); + + // Also include global credentials if scopes allow read-only access + if (this.hasGlobalReadOnlyAccess(scopes)) { + const globalCreds = await this.credentialsRepository.find({ + where: { id: In(credentialIds), isGlobal: true }, + select: ['id'], + }); + for (const gc of globalCreds) result.add(gc.id); + } + + return result; + } + async getCredentialIdsByUserAndRole( userIds: string[], options: diff --git a/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts new file mode 100644 index 000000000000..83c897c2ef7f --- /dev/null +++ b/packages/cli/src/modules/workflow-index/workflow-dependency-query.service.ts @@ -0,0 +1,288 @@ +import type { + DependenciesBatchResponse, + DependencyCountsBatchResponse, + DependencyResourceType, + ResolvedDependency, +} from '@n8n/api-types'; +import { + CredentialsRepository, + ProjectRelationRepository, + WorkflowDependencyRepository, + WorkflowRepository, +} from '@n8n/db'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { hasGlobalScope } from '@n8n/permissions'; +import { In } from '@n8n/typeorm'; + +import { CredentialsFinderService } from '@/credentials/credentials-finder.service'; +import { DataTableRepository } from '@/modules/data-table/data-table.repository'; +import { RoleService } from '@/services/role.service'; +import { WorkflowFinderService } from '@/workflows/workflow-finder.service'; + +interface RawDepMaps { + credMap: Map>; + dtMap: Map>; + subMap: Map>; + parentMap: Map>; + allCredIds: Set; + allWfIds: Set; + allDtIds: Set; +} + +@Service() +export class WorkflowDependencyQueryService { + constructor( + private readonly dependencyRepository: WorkflowDependencyRepository, + private readonly credentialsRepository: CredentialsRepository, + private readonly workflowRepository: WorkflowRepository, + private readonly dataTableRepository: DataTableRepository, + private readonly workflowFinderService: WorkflowFinderService, + private readonly credentialsFinderService: CredentialsFinderService, + private readonly projectRelationRepository: ProjectRelationRepository, + private readonly roleService: RoleService, + ) {} + + async getDependencyCounts( + resourceIds: string[], + resourceType: DependencyResourceType, + user: User, + ): Promise { + const loaded = await this.loadDepsForResources(resourceIds, resourceType, user); + if (!loaded) return {}; + + const { accessibleInputIds, maps } = loaded; + + const result: DependencyCountsBatchResponse = {}; + for (const id of accessibleInputIds) { + result[id] = { + credentialId: maps.credMap.get(id)?.size ?? 0, + dataTableId: maps.dtMap.get(id)?.size ?? 0, + workflowCall: maps.subMap.get(id)?.size ?? 0, + workflowParent: maps.parentMap.get(id)?.size ?? 0, + }; + } + return result; + } + + /** Return resolved dependencies for each input resource, excluding inaccessible ones. */ + async getResourceDependencies( + resourceIds: string[], + resourceType: DependencyResourceType, + user: User, + ): Promise { + const loaded = await this.loadDepsForResources(resourceIds, resourceType, user); + if (!loaded) return {}; + + const { accessibleInputIds, maps } = loaded; + + // Check user access for each dependency type + const [accessibleWfIds, accessibleCredIds, accessibleDtIds] = await Promise.all([ + this.filterByAccess([...maps.allWfIds], 'workflow', user), + this.filterByAccess([...maps.allCredIds], 'credential', user), + this.filterByAccess([...maps.allDtIds], 'dataTable', user), + ]); + + // Only enrich names for accessible resources + const [credentials, workflows, dataTables] = await Promise.all([ + accessibleCredIds.length > 0 + ? this.credentialsRepository.find({ + where: { id: In(accessibleCredIds) }, + select: ['id', 'name'], + }) + : [], + accessibleWfIds.length > 0 + ? this.workflowRepository.find({ + where: { id: In(accessibleWfIds) }, + select: ['id', 'name'], + }) + : [], + accessibleDtIds.length > 0 + ? this.dataTableRepository.find({ + where: { id: In(accessibleDtIds) }, + select: ['id', 'name', 'projectId'], + }) + : [], + ]); + + const wfNames = new Map(); + const credNames = new Map(); + const dtNames = new Map(); + + for (const c of credentials) credNames.set(c.id, c.name ?? c.id); + for (const w of workflows) wfNames.set(w.id, w.name ?? w.id); + for (const dt of dataTables) + dtNames.set(dt.id, { name: dt.name ?? dt.id, projectId: dt.projectId }); + + return this.buildEnrichedResult(accessibleInputIds, maps, { + wfNames, + credNames, + dtNames, + }); + } + + private async loadDepsForResources( + resourceIds: string[], + resourceType: DependencyResourceType, + user: User, + ): Promise<{ accessibleInputIds: string[]; maps: RawDepMaps } | null> { + const accessibleInputIds = await this.filterByAccess(resourceIds, resourceType, user); + if (accessibleInputIds.length === 0) return null; + + const rawDeps = await this.dependencyRepository.find({ + where: [ + { + workflowId: In(accessibleInputIds), + dependencyType: In(['credentialId', 'dataTableId', 'workflowCall']), + }, + { dependencyKey: In(accessibleInputIds) }, + ], + select: ['workflowId', 'dependencyType', 'dependencyKey'], + }); + + if (rawDeps.length === 0) return null; + + return { accessibleInputIds, maps: this.buildDepMaps(rawDeps) }; + } + + private buildDepMaps( + rawDeps: Array<{ workflowId: string; dependencyType: string; dependencyKey: string }>, + ): RawDepMaps { + const credMap = new Map>(); + const dtMap = new Map>(); + const subMap = new Map>(); + const parentMap = new Map>(); + const allCredIds = new Set(); + const allWfIds = new Set(); + const allDtIds = new Set(); + + for (const dep of rawDeps) { + allWfIds.add(dep.workflowId); + addToSet(parentMap, dep.dependencyKey, dep.workflowId); + switch (dep.dependencyType) { + case 'credentialId': + addToSet(credMap, dep.workflowId, dep.dependencyKey); + allCredIds.add(dep.dependencyKey); + break; + case 'dataTableId': + addToSet(dtMap, dep.workflowId, dep.dependencyKey); + allDtIds.add(dep.dependencyKey); + break; + case 'workflowCall': + addToSet(subMap, dep.workflowId, dep.dependencyKey); + allWfIds.add(dep.dependencyKey); + break; + } + } + + return { credMap, dtMap, subMap, parentMap, allCredIds, allWfIds, allDtIds }; + } + + /** Build enriched result — only includes accessible deps, counts inaccessible ones. */ + private buildEnrichedResult( + resourceIds: string[], + maps: RawDepMaps, + accessMaps: { + wfNames: Map; + credNames: Map; + dtNames: Map; + }, + ): DependenciesBatchResponse { + const result: DependenciesBatchResponse = {}; + + for (const resourceId of resourceIds) { + const dependencies: ResolvedDependency[] = []; + let inaccessibleCount = 0; + + const resolve = ( + ids: Set | undefined, + nameMap: Map, + type: ResolvedDependency['type'], + ) => { + for (const id of ids ?? []) { + const name = nameMap.get(id); + if (name !== undefined) { + dependencies.push({ id, name, type }); + } else { + inaccessibleCount++; + } + } + }; + + resolve(maps.subMap.get(resourceId), accessMaps.wfNames, 'workflowCall'); + resolve(maps.parentMap.get(resourceId), accessMaps.wfNames, 'workflowParent'); + resolve(maps.credMap.get(resourceId), accessMaps.credNames, 'credentialId'); + + for (const id of maps.dtMap.get(resourceId) ?? []) { + const dt = accessMaps.dtNames.get(id); + if (dt) { + dependencies.push({ id, name: dt.name, type: 'dataTableId', projectId: dt.projectId }); + } else { + inaccessibleCount++; + } + } + + result[resourceId] = { dependencies, inaccessibleCount }; + } + + return result; + } + + /** + * Filter a list of resource IDs to only those the user has access to. + */ + private async filterByAccess( + ids: string[], + resourceType: DependencyResourceType, + user: User, + ): Promise { + if (ids.length === 0) return []; + + switch (resourceType) { + case 'workflow': { + const accessible = await this.workflowFinderService.findWorkflowIdsWithScopeForUser( + ids, + user, + ['workflow:read'], + ); + return ids.filter((id) => accessible.has(id)); + } + case 'credential': { + const accessible = await this.credentialsFinderService.findCredentialIdsWithScopeForUser( + ids, + user, + ['credential:read'], + ); + return ids.filter((id) => accessible.has(id)); + } + case 'dataTable': { + return await this.filterDataTableIdsByAccess(ids, user); + } + } + } + + private async filterDataTableIdsByAccess(ids: string[], user: User): Promise { + if (hasGlobalScope(user, 'dataTable:listProject')) return ids; + + const dataTables = await this.dataTableRepository.find({ + where: { id: In(ids) }, + select: ['id', 'projectId'], + }); + + const roles = await this.roleService.rolesWithScope('project', ['dataTable:listProject']); + const accessibleProjectIds = new Set( + await this.projectRelationRepository.getAccessibleProjectsByRoles(user.id, roles), + ); + + return dataTables.filter((dt) => accessibleProjectIds.has(dt.projectId)).map((dt) => dt.id); + } +} + +function addToSet(map: Map>, key: string, val: string) { + let set = map.get(key); + if (!set) { + set = new Set(); + map.set(key, set); + } + set.add(val); +} diff --git a/packages/cli/src/modules/workflow-index/workflow-dependency.controller.ts b/packages/cli/src/modules/workflow-index/workflow-dependency.controller.ts new file mode 100644 index 000000000000..d2c42f333863 --- /dev/null +++ b/packages/cli/src/modules/workflow-index/workflow-dependency.controller.ts @@ -0,0 +1,52 @@ +import { GetResourceDependenciesDto, GetResourceDependencyCountsDto } from '@n8n/api-types'; +import { WorkflowsConfig } from '@n8n/config'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Body, Post, RestController } from '@n8n/decorators'; + +import { ServiceUnavailableError } from '@/errors/response-errors/service-unavailable.error'; + +import { WorkflowDependencyQueryService } from './workflow-dependency-query.service'; + +@RestController('/workflow-dependencies') +export class WorkflowDependencyController { + constructor( + private readonly workflowDependencyQueryService: WorkflowDependencyQueryService, + private readonly workflowsConfig: WorkflowsConfig, + ) {} + + @Post('/counts') + async getResourceDependencyCounts( + req: AuthenticatedRequest, + _res: unknown, + @Body body: GetResourceDependencyCountsDto, + ) { + this.assertIndexingEnabled(); + + return await this.workflowDependencyQueryService.getDependencyCounts( + body.resourceIds, + body.resourceType, + req.user, + ); + } + + @Post('/details') + async getResourceDependencies( + req: AuthenticatedRequest, + _res: unknown, + @Body body: GetResourceDependenciesDto, + ) { + this.assertIndexingEnabled(); + + return await this.workflowDependencyQueryService.getResourceDependencies( + body.resourceIds, + body.resourceType, + req.user, + ); + } + + private assertIndexingEnabled() { + if (!this.workflowsConfig.indexingEnabled) { + throw new ServiceUnavailableError('Workflow dependency indexing is not enabled'); + } + } +} diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 9d29f45d3e33..aa869b82e2b4 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -61,6 +61,7 @@ import '@/license/license.controller'; import '@/evaluation.ee/test-runs.controller.ee'; import '@/workflows/workflow-history/workflow-history.controller'; import '@/workflows/workflows.controller'; +import '@/modules/workflow-index/workflow-dependency.controller'; import '@/webhooks/webhooks.controller'; import { ChatServer } from './chat/chat-server'; diff --git a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts index 85262b14d7c7..74d62f436225 100644 --- a/packages/cli/src/services/__tests__/credentials-finder.service.test.ts +++ b/packages/cli/src/services/__tests__/credentials-finder.service.test.ts @@ -704,6 +704,127 @@ describe('CredentialsFinderService', () => { }); }); + describe('findCredentialIdsWithScopeForUser', () => { + const owner = mock({ role: GLOBAL_OWNER_ROLE }); + const member = mock({ role: GLOBAL_MEMBER_ROLE, id: 'user123' }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return empty set for empty input', async () => { + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser([], member, [ + 'credential:read', + ]); + + expect(result).toEqual(new Set()); + expect(sharedCredentialsRepository.find).not.toHaveBeenCalled(); + }); + + test('should return all requested IDs for global owner', async () => { + const ids = ['cred-1', 'cred-2']; + sharedCredentialsRepository.find.mockResolvedValueOnce([ + mock({ credentialsId: 'cred-1' }), + mock({ credentialsId: 'cred-2' }), + ]); + // Global credentials query for read-only scope + credentialsRepository.find.mockResolvedValueOnce([]); + + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser(ids, owner, [ + 'credential:read', + ]); + + expect(result).toEqual(new Set(['cred-1', 'cred-2'])); + // Owner should not trigger role resolution + expect(roleService.rolesWithScope).not.toHaveBeenCalled(); + expect(sharedCredentialsRepository.find).toHaveBeenCalledWith({ + select: { credentialsId: true }, + where: { credentialsId: In(ids) }, + }); + }); + + test('should filter by roles for regular member', async () => { + const ids = ['cred-1', 'cred-2', 'cred-3']; + sharedCredentialsRepository.find.mockResolvedValueOnce([ + mock({ credentialsId: 'cred-1' }), + ]); + credentialsRepository.find.mockResolvedValueOnce([]); + + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser(ids, member, [ + 'credential:read', + ]); + + expect(result).toEqual(new Set(['cred-1'])); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('project', ['credential:read']); + expect(roleService.rolesWithScope).toHaveBeenCalledWith('credential', ['credential:read']); + expect(sharedCredentialsRepository.find).toHaveBeenCalledWith({ + select: { credentialsId: true }, + where: { + credentialsId: In(ids), + role: In(['credential:owner', 'credential:user']), + project: { + projectRelations: { + role: In([ + PROJECT_ADMIN_ROLE_SLUG, + PROJECT_OWNER_ROLE_SLUG, + PROJECT_EDITOR_ROLE_SLUG, + PROJECT_VIEWER_ROLE_SLUG, + ]), + userId: member.id, + }, + }, + }, + }); + }); + + test('should include global credentials for read-only scope', async () => { + const ids = ['cred-1', 'global-1']; + sharedCredentialsRepository.find.mockResolvedValueOnce([ + mock({ credentialsId: 'cred-1' }), + ]); + credentialsRepository.find.mockResolvedValueOnce([ + mock({ id: 'global-1' }), + ]); + + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser(ids, member, [ + 'credential:read', + ]); + + expect(result).toEqual(new Set(['cred-1', 'global-1'])); + expect(credentialsRepository.find).toHaveBeenCalledWith({ + where: { id: In(ids), isGlobal: true }, + select: ['id'], + }); + }); + + test('should not include global credentials for write scopes', async () => { + const ids = ['cred-1']; + sharedCredentialsRepository.find.mockResolvedValueOnce([ + mock({ credentialsId: 'cred-1' }), + ]); + + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser(ids, member, [ + 'credential:update', + ]); + + expect(result).toEqual(new Set(['cred-1'])); + expect(credentialsRepository.find).not.toHaveBeenCalled(); + }); + + test('should return empty set when member has no access', async () => { + sharedCredentialsRepository.find.mockResolvedValueOnce([]); + credentialsRepository.find.mockResolvedValueOnce([]); + + const result = await credentialsFinderService.findCredentialIdsWithScopeForUser( + ['cred-1', 'cred-2'], + member, + ['credential:read'], + ); + + expect(result).toEqual(new Set()); + }); + }); + describe('getCredentialIdsByUserAndRole', () => { const userIds = ['user1', 'user2']; const mockSharings = [ diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index 17ca341edcad..13d5b9ac6fc7 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -47,7 +47,8 @@ type EndpointGroup = | 'security-settings' | 'data-table' | 'third-party-licenses' - | 'mcp'; + | 'mcp' + | 'workflowDependencies'; type ModuleName = | 'insights' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 2cf0faa68104..ab47c165a991 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -179,6 +179,10 @@ export const setupTestServer = ({ await import('@/workflows/workflows.controller'); break; + case 'workflowDependencies': + await import('@/modules/workflow-index/workflow-dependency.controller'); + break; + case 'executions': await import('@/executions/executions.controller'); break; diff --git a/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts new file mode 100644 index 000000000000..b1a9b927dc16 --- /dev/null +++ b/packages/cli/test/integration/workflows/workflow-dependency.controller.test.ts @@ -0,0 +1,442 @@ +import { + createWorkflow, + randomCredentialPayload, + shareWorkflowWithUsers, +} from '@n8n/backend-test-utils'; +import { WorkflowsConfig } from '@n8n/config'; +import { WorkflowDependencyRepository } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createMember, createOwner } from '../shared/db/users'; +import { saveCredential } from '../shared/db/credentials'; +import * as utils from '../shared/utils'; + +let testServer: ReturnType; +let depRepo: WorkflowDependencyRepository; +let workflowsConfig: WorkflowsConfig; + +testServer = utils.setupTestServer({ + endpointGroups: ['workflowDependencies'], + enabledFeatures: ['feat:sharing', 'feat:advancedPermissions'], +}); + +beforeAll(() => { + depRepo = Container.get(WorkflowDependencyRepository); + workflowsConfig = Container.get(WorkflowsConfig); +}); + +afterEach(() => { + workflowsConfig.indexingEnabled = true; +}); + +/** Seed a workflow_dependency row (draft). */ +async function seedDep(workflowId: string, dependencyType: string, dependencyKey: string) { + await depRepo.save( + depRepo.create({ + workflowId, + workflowVersionId: 1, + publishedVersionId: null, + dependencyType: dependencyType as 'credentialId', + dependencyKey, + dependencyInfo: null, + indexVersionId: 1, + }), + ); +} + +describe('POST /workflow-dependencies/counts', () => { + it('should return counts only for workflows the user owns', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const ownerWorkflow = await createWorkflow({}, owner); + const memberWorkflow = await createWorkflow({}, member); + + await seedDep(ownerWorkflow.id, 'credentialId', 'cred-1'); + await seedDep(memberWorkflow.id, 'credentialId', 'cred-2'); + + // Member queries both workflows — should only see their own + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [ownerWorkflow.id, memberWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).not.toHaveProperty(ownerWorkflow.id); + expect(resp.body.data).toHaveProperty(memberWorkflow.id); + expect(resp.body.data[memberWorkflow.id].credentialId).toBe(1); + }); + + it('should return counts for credential resources the user owns', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const ownerCred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + const memberCred = await saveCredential(randomCredentialPayload(), { + user: member, + role: 'credential:owner', + }); + + // Create a workflow that uses both credentials + const wf = await createWorkflow({}, owner); + await seedDep(wf.id, 'credentialId', ownerCred.id); + await seedDep(wf.id, 'credentialId', memberCred.id); + + // Member queries dependency counts for both credentials + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [ownerCred.id, memberCred.id], + resourceType: 'credential', + }); + + expect(resp.statusCode).toBe(200); + // Member can only see their own credential + expect(resp.body.data).not.toHaveProperty(ownerCred.id); + expect(resp.body.data).toHaveProperty(memberCred.id); + expect(resp.body.data[memberCred.id].workflowParent).toBe(1); + }); + + it('should include counts for dependencies the user cannot access', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const memberWorkflow = await createWorkflow({}, member); + const ownerCred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + // memberWorkflow uses a credential owned by the owner + await seedDep(memberWorkflow.id, 'credentialId', ownerCred.id); + + // Member queries counts for their own workflow + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [memberWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + // The credential count should include the inaccessible credential + expect(resp.body.data[memberWorkflow.id].credentialId).toBe(1); + }); + + it('should count multiple dependency types on the same workflow', async () => { + const owner = await createOwner(); + + const workflow = await createWorkflow({}, owner); + const subWorkflow = await createWorkflow({}, owner); + const cred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + await seedDep(workflow.id, 'credentialId', cred.id); + await seedDep(workflow.id, 'workflowCall', subWorkflow.id); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [workflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data[workflow.id]).toMatchObject({ + credentialId: 1, + workflowCall: 1, + dataTableId: 0, + workflowParent: 0, + }); + }); + + it('should return zero counts for an accessible resource with no dependencies', async () => { + const owner = await createOwner(); + const workflow = await createWorkflow({}, owner); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [workflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + // Accessible but no deps → empty object (no entry) + expect(resp.body.data).not.toHaveProperty(workflow.id); + }); + + it('owner can see all workflows', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const ownerWorkflow = await createWorkflow({}, owner); + const memberWorkflow = await createWorkflow({}, member); + + await seedDep(ownerWorkflow.id, 'credentialId', 'cred-1'); + await seedDep(memberWorkflow.id, 'credentialId', 'cred-2'); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: [ownerWorkflow.id, memberWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toHaveProperty(ownerWorkflow.id); + expect(resp.body.data).toHaveProperty(memberWorkflow.id); + }); +}); + +describe('POST /workflow-dependencies/details', () => { + it('should filter out inaccessible input resourceIds', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const memberWorkflow = await createWorkflow({}, member); + const ownerWorkflow = await createWorkflow({}, owner); + + await seedDep(memberWorkflow.id, 'credentialId', 'cred-1'); + await seedDep(ownerWorkflow.id, 'credentialId', 'cred-2'); + + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [memberWorkflow.id, ownerWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toHaveProperty(memberWorkflow.id); + expect(resp.body.data).not.toHaveProperty(ownerWorkflow.id); + }); + + it('should resolve names for accessible dependencies', async () => { + const owner = await createOwner(); + + const workflow1 = await createWorkflow({ name: 'Main WF' }, owner); + const workflow2 = await createWorkflow({ name: 'Sub WF' }, owner); + + const cred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + + await seedDep(workflow1.id, 'workflowCall', workflow2.id); + await seedDep(workflow1.id, 'credentialId', cred.id); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [workflow1.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + const result = resp.body.data[workflow1.id]; + expect(result.dependencies).toHaveLength(2); + expect(result.inaccessibleCount).toBe(0); + + const subWf = result.dependencies.find((d: { type: string }) => d.type === 'workflowCall'); + expect(subWf).toMatchObject({ id: workflow2.id, name: 'Sub WF', type: 'workflowCall' }); + + const credDep = result.dependencies.find((d: { type: string }) => d.type === 'credentialId'); + expect(credDep).toMatchObject({ id: cred.id, name: cred.name, type: 'credentialId' }); + }); + + it('should return empty object when no resourceIds pass access filtering', async () => { + const member = await createMember(); + const otherMember = await createMember(); + + const otherWorkflow = await createWorkflow({}, otherMember); + await seedDep(otherWorkflow.id, 'credentialId', 'cred-1'); + + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [otherWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toEqual({}); + }); + + it('should return parent workflows for a credential resource', async () => { + const owner = await createOwner(); + + const cred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + const parentWf = await createWorkflow({ name: 'Parent WF' }, owner); + await seedDep(parentWf.id, 'credentialId', cred.id); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [cred.id], + resourceType: 'credential', + }); + + expect(resp.statusCode).toBe(200); + const result = resp.body.data[cred.id]; + expect(result.dependencies).toHaveLength(1); + expect(result.inaccessibleCount).toBe(0); + expect(result.dependencies[0]).toMatchObject({ + id: parentWf.id, + name: 'Parent WF', + type: 'workflowParent', + }); + }); + + it('should exclude inaccessible deps and report inaccessibleCount', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const memberWorkflow = await createWorkflow({ name: 'Member WF' }, member); + const ownerWorkflow = await createWorkflow({ name: 'Owner WF' }, owner); + const memberSubWorkflow = await createWorkflow({ name: 'Member Sub' }, member); + + // memberWorkflow calls both an accessible and an inaccessible sub-workflow + await seedDep(memberWorkflow.id, 'workflowCall', ownerWorkflow.id); + await seedDep(memberWorkflow.id, 'workflowCall', memberSubWorkflow.id); + + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [memberWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + const result = resp.body.data[memberWorkflow.id]; + expect(result.dependencies).toHaveLength(1); + expect(result.inaccessibleCount).toBe(1); + + expect(result.dependencies[0]).toMatchObject({ + id: memberSubWorkflow.id, + name: 'Member Sub', + type: 'workflowCall', + }); + }); + + it('should return details for a shared workflow', async () => { + const owner = await createOwner(); + const member = await createMember(); + + const sharedWorkflow = await createWorkflow({ name: 'Shared WF' }, owner); + await shareWorkflowWithUsers(sharedWorkflow, [member]); + + const cred = await saveCredential(randomCredentialPayload(), { + user: owner, + role: 'credential:owner', + }); + await seedDep(sharedWorkflow.id, 'credentialId', cred.id); + + // Member can access the shared workflow + const resp = await testServer + .authAgentFor(member) + .post('/workflow-dependencies/details') + .send({ + resourceIds: [sharedWorkflow.id], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(200); + expect(resp.body.data).toHaveProperty(sharedWorkflow.id); + // Member can access the shared workflow but not the owner's credential, + // so the credential shows up as inaccessible rather than a resolved dependency + expect(resp.body.data[sharedWorkflow.id].dependencies).toHaveLength(0); + expect(resp.body.data[sharedWorkflow.id].inaccessibleCount).toBe(1); + }); + + it('should require resourceType in the request body', async () => { + const owner = await createOwner(); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/details') + .send({ + resourceIds: ['some-id'], + }); + + expect(resp.statusCode).toBe(400); + }); + + it('should reject invalid resourceType', async () => { + const owner = await createOwner(); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/details') + .send({ + resourceIds: ['some-id'], + resourceType: 'invalid', + }); + + expect(resp.statusCode).toBe(400); + }); + + it('should reject empty resourceIds array', async () => { + const owner = await createOwner(); + + const resp = await testServer.authAgentFor(owner).post('/workflow-dependencies/details').send({ + resourceIds: [], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(400); + }); +}); + +describe('indexing disabled', () => { + it('should return 503 for counts when indexing is disabled', async () => { + workflowsConfig.indexingEnabled = false; + const owner = await createOwner(); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/counts') + .send({ + resourceIds: ['some-id'], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(503); + }); + + it('should return 503 for details when indexing is disabled', async () => { + workflowsConfig.indexingEnabled = false; + const owner = await createOwner(); + + const resp = await testServer + .authAgentFor(owner) + .post('/workflow-dependencies/details') + .send({ + resourceIds: ['some-id'], + resourceType: 'workflow', + }); + + expect(resp.statusCode).toBe(503); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.types.ts b/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.types.ts index 26a07247e3d8..8868c6955583 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.types.ts +++ b/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.types.ts @@ -131,6 +131,8 @@ export interface DropdownMenuSlots { loading?: () => void; /** Custom empty state when no items */ empty?: () => void; + /** Content rendered after the item list (e.g. info notices) */ + footer?: () => void; /** Icon or content before the search input (default: search icon) */ 'search-prefix'?: () => void; /** Icon or content after the search input */ diff --git a/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.vue b/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.vue index c383a760d999..5c825cfb7ed1 100644 --- a/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.vue +++ b/packages/frontend/@n8n/design-system/src/v2/components/DropdownMenu/DropdownMenu.vue @@ -351,6 +351,7 @@ defineExpose({ open, close }); + diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 3ed11654c362..70303d199d23 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -3795,6 +3795,20 @@ "workflows.dynamic.tooltip": "This workflow resolves credentials at runtime based on who executes it.", "workflows.dynamic.tooltipTitle": "Dynamic credentials", "workflows.dynamic.resolverMissing": "Credential resolver missing", + "workflows.dependencies.tooltip": "1 dependency | {count} dependencies", + "workflows.dependencies.modal.title": "Dependencies", + "workflows.dependencies.modal.search": "Search dependencies...", + "workflows.dependencies.type.credentials": "Credentials", + "workflows.dependencies.type.dataTables": "Data Tables", + "workflows.dependencies.type.subWorkflows": "Sub-workflows", + "workflows.dependencies.type.parentWorkflows": "Used by workflows", + "workflows.dependencies.search.placeholder": "Search...", + "workflows.dependencies.search.empty": "No matching dependencies", + "workflows.dependencies.hiddenNotice": "+1 not accessible to you | +{count} not accessible to you", + "resourceDependents.tooltip": "Used by 1 workflow | Used by {count} workflows", + "resourceDependents.modal.title": "Used by workflows", + "resourceDependents.modal.search": "Search workflows...", + "resourceDependents.type.workflows": "Workflows", "workflows.item.enableMCPAccess": "Enable MCP access", "workflows.item.disableMCPAccess": "Remove MCP access", "workflows.itemSuggestion.try": "Try template", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 20157b08bc3d..d0794ef48e92 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -344,7 +344,7 @@ export type WorkflowListItem = Omit< IWorkflowDb, 'nodes' | 'connections' | 'pinData' | 'usedCredentials' | 'meta' > & { - resource: 'workflow'; + resource?: 'workflow'; // only included if list may contain folders description?: string; hasResolvableCredentials?: boolean; }; diff --git a/packages/frontend/editor-ui/src/app/api/workflow-dependencies.ts b/packages/frontend/editor-ui/src/app/api/workflow-dependencies.ts new file mode 100644 index 000000000000..1e958d69d630 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/api/workflow-dependencies.ts @@ -0,0 +1,33 @@ +import type { IRestApiContext } from '@n8n/rest-api-client'; +import type { + DependenciesBatchResponse, + DependencyCountsBatchResponse, + DependencyResourceType, +} from '@n8n/api-types'; +import { makeRestApiRequest } from '@n8n/rest-api-client'; + +export async function getResourceDependencyCounts( + context: IRestApiContext, + resourceIds: string[], + resourceType: DependencyResourceType, +) { + return await makeRestApiRequest( + context, + 'POST', + '/workflow-dependencies/counts', + { resourceIds, resourceType }, + ); +} + +export async function getResourceDependencies( + context: IRestApiContext, + resourceIds: string[], + resourceType: DependencyResourceType, +) { + return await makeRestApiRequest( + context, + 'POST', + '/workflow-dependencies/details', + { resourceIds, resourceType }, + ); +} diff --git a/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts b/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts new file mode 100644 index 000000000000..10d9040b7cd4 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/DependencyPill.test.ts @@ -0,0 +1,319 @@ +import { createComponentRenderer } from '@/__tests__/render'; +import DependencyPill from '@/app/components/DependencyPill.vue'; +import { createTestingPinia } from '@pinia/testing'; +import { useUIStore } from '@/app/stores/ui.store'; +import { mockedStore } from '@/__tests__/utils'; +import { VIEWS } from '@/app/constants'; +import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants'; +import * as vueRouter from 'vue-router'; +import type { MockInstance } from 'vitest'; + +const telemetryTrackMock = vi.fn(); +vi.mock('@/app/composables/useTelemetry', () => ({ + useTelemetry: () => ({ track: telemetryTrackMock }), +})); + +vi.mock('vue-router', () => { + const resolve = vi.fn().mockReturnValue({ href: '/mock-href' }); + return { + useRouter: () => ({ resolve }), + useRoute: () => ({ params: {} }), + RouterLink: vi.fn(), + }; +}); + +let mockDepsResult: + | { + dependencies: Array<{ type: string; id: string; name: string; projectId?: string }>; + inaccessibleCount: number; + } + | undefined; + +vi.mock('@/app/composables/useDependencies', () => ({ + useDependencies: () => ({ + getDependencies: () => mockDepsResult, + fetchDependencies: vi.fn(), + getTotalCount: () => 0, + }), +})); + +let capturedSelectHandler: ((value: string) => void) | undefined; +let capturedOpenHandler: ((open: boolean) => void) | undefined; +let capturedItems: unknown[] = []; +let capturedSearchable: boolean | undefined; + +vi.mock('@n8n/design-system/v2/components/DropdownMenu', () => ({ + N8nDropdownMenu: { + name: 'N8nDropdownMenu', + props: [ + 'items', + 'trigger', + 'placement', + 'loading', + 'searchable', + 'searchPlaceholder', + 'emptyText', + 'maxHeight', + 'dataTestId', + 'extraPopperClass', + ], + emits: ['select', 'search', 'update:modelValue'], + setup( + props: { items: unknown[]; searchable: boolean }, + { emit }: { emit: (e: string, v: unknown) => void }, + ) { + capturedItems = props.items; + capturedSearchable = props.searchable; + capturedSelectHandler = (value: string) => emit('select', value); + capturedOpenHandler = (open: boolean) => emit('update:modelValue', open); + }, + template: + '
', + }, +})); + +const renderComponent = createComponentRenderer(DependencyPill, { + pinia: createTestingPinia(), +}); + +const createDepsResult = (inaccessibleCount = 0) => ({ + dependencies: [ + { type: 'credentialId', id: 'cred-1', name: 'My API Key' }, + { type: 'dataTableId', id: 'dt-1', name: 'Users Table', projectId: 'proj-1' }, + { type: 'workflowCall', id: 'wf-1', name: 'Sub-Workflow A' }, + { type: 'workflowParent', id: 'wf-2', name: 'Parent Workflow B' }, + ], + inaccessibleCount, +}); + +const defaultProps = { + resourceType: 'workflow' as const, + resourceId: 'wf-test', + source: 'workflow_card' as const, +}; + +describe('DependencyPill', () => { + let windowOpenSpy: MockInstance; + let router: ReturnType; + + beforeEach(() => { + router = vueRouter.useRouter(); + windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + capturedSelectHandler = undefined; + capturedOpenHandler = undefined; + capturedItems = []; + capturedSearchable = undefined; + mockDepsResult = undefined; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should render badge with dependency count', () => { + mockDepsResult = createDepsResult(); + const { getByText } = renderComponent({ props: defaultProps }); + + expect(getByText(String(mockDepsResult.dependencies.length))).toBeInTheDocument(); + }); + + it('should render badge with zero count when no dependencies', () => { + const { getByText } = renderComponent({ props: defaultProps }); + + expect(getByText('0')).toBeInTheDocument(); + }); + + it('should build menu items grouped by type', () => { + mockDepsResult = createDepsResult(); + renderComponent({ props: defaultProps }); + + const items = capturedItems as Array<{ id: string; label: string; disabled?: boolean }>; + + // Should have 4 headers + 4 items = 8 total + expect(items).toHaveLength(8); + + // First group: credentials header + item + expect(items[0].id).toBe('header-credentialId'); + expect(items[0].disabled).toBe(true); + expect(items[1].id).toBe('credentialId:cred-1'); + + // Second group: data tables header + item (should have divider) + expect(items[2].id).toBe('header-dataTableId'); + expect(items[3].id).toBe('dataTableId:dt-1'); + + // Third group: sub-workflows + expect(items[4].id).toBe('header-workflowCall'); + expect(items[5].id).toBe('workflowCall:wf-1'); + + // Fourth group: parent workflows + expect(items[6].id).toBe('header-workflowParent'); + expect(items[7].id).toBe('workflowParent:wf-2'); + }); + + it('should open credential on select', () => { + mockDepsResult = { + dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'My Key' }], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + const uiStore = mockedStore(useUIStore); + + capturedSelectHandler?.('credentialId:cred-1'); + + expect(uiStore.openExistingCredential).toHaveBeenCalledWith('cred-1'); + }); + + it('should open workflow in new tab on select', () => { + mockDepsResult = { + dependencies: [{ type: 'workflowCall', id: 'wf-1', name: 'Sub WF' }], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + + capturedSelectHandler?.('workflowCall:wf-1'); + + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW, + params: { name: 'wf-1' }, + }); + expect(windowOpenSpy).toHaveBeenCalledWith('/mock-href', '_blank'); + }); + + it('should open parent workflow in new tab on select', () => { + mockDepsResult = { + dependencies: [{ type: 'workflowParent', id: 'wf-2', name: 'Parent WF' }], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + + capturedSelectHandler?.('workflowParent:wf-2'); + + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW, + params: { name: 'wf-2' }, + }); + expect(windowOpenSpy).toHaveBeenCalledWith('/mock-href', '_blank'); + }); + + it('should open data table in new tab on select', () => { + mockDepsResult = { + dependencies: [{ type: 'dataTableId', id: 'dt-1', name: 'My Table', projectId: 'proj-1' }], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + + capturedSelectHandler?.('dataTableId:dt-1'); + + expect(router.resolve).toHaveBeenCalledWith({ + name: DATA_TABLE_DETAILS, + params: { projectId: 'proj-1', id: 'dt-1' }, + }); + expect(windowOpenSpy).toHaveBeenCalledWith('/mock-href', '_blank'); + }); + + it('should not open data table without projectId', () => { + mockDepsResult = { + dependencies: [{ type: 'dataTableId', id: 'dt-1', name: 'My Table' }], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + + capturedSelectHandler?.('dataTableId:dt-1'); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it('should ignore select with invalid value', () => { + mockDepsResult = createDepsResult(); + renderComponent({ props: defaultProps }); + const uiStore = mockedStore(useUIStore); + + capturedSelectHandler?.('invalid'); + + expect(uiStore.openExistingCredential).not.toHaveBeenCalled(); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it('should hide search when fewer than 6 dependencies', () => { + mockDepsResult = createDepsResult(); // 4 items + renderComponent({ props: defaultProps }); + + expect(capturedSearchable).toBe(false); + }); + + it('should show search when 6 or more dependencies', () => { + mockDepsResult = { + dependencies: [ + { type: 'credentialId', id: 'c-1', name: 'Cred 1' }, + { type: 'credentialId', id: 'c-2', name: 'Cred 2' }, + { type: 'credentialId', id: 'c-3', name: 'Cred 3' }, + { type: 'workflowCall', id: 'w-1', name: 'WF 1' }, + { type: 'workflowCall', id: 'w-2', name: 'WF 2' }, + { type: 'workflowCall', id: 'w-3', name: 'WF 3' }, + ], + inaccessibleCount: 0, + }; + renderComponent({ props: defaultProps }); + + expect(capturedSearchable).toBe(true); + }); + + it('should track telemetry when dropdown opens', () => { + mockDepsResult = createDepsResult(); + renderComponent({ props: { ...defaultProps, source: 'credential_card' } }); + + capturedOpenHandler?.(true); + + expect(telemetryTrackMock).toHaveBeenCalledWith('User opened dependency pill', { + source: 'credential_card', + dependency_count: mockDepsResult.dependencies.length, + }); + }); + + it('should not track telemetry when dropdown closes', () => { + mockDepsResult = createDepsResult(); + renderComponent({ props: defaultProps }); + + capturedOpenHandler?.(false); + + expect(telemetryTrackMock).not.toHaveBeenCalled(); + }); + + it('should include inaccessible count in badge total', () => { + mockDepsResult = { + dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'My API Key' }], + inaccessibleCount: 2, + }; + const { getByText } = renderComponent({ props: defaultProps }); + + // Badge should show 1 accessible + 2 inaccessible = 3 + expect(getByText('3')).toBeInTheDocument(); + }); + + it('should show hidden notice with count when inaccessibleCount > 0', () => { + mockDepsResult = { + dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'My API Key' }], + inaccessibleCount: 1, + }; + const { getByText } = renderComponent({ props: defaultProps }); + + expect(getByText('+1 not accessible to you')).toBeInTheDocument(); + }); + + it('should pluralize hidden notice for multiple inaccessible deps', () => { + mockDepsResult = { + dependencies: [{ type: 'credentialId', id: 'cred-1', name: 'My API Key' }], + inaccessibleCount: 3, + }; + const { getByText } = renderComponent({ props: defaultProps }); + + expect(getByText('+3 not accessible to you')).toBeInTheDocument(); + }); + + it('should not show hidden notice when inaccessibleCount is 0', () => { + mockDepsResult = createDepsResult(); + const { queryByText } = renderComponent({ props: defaultProps }); + + expect(queryByText(/not accessible to you/)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/frontend/editor-ui/src/app/components/DependencyPill.vue b/packages/frontend/editor-ui/src/app/components/DependencyPill.vue new file mode 100644 index 000000000000..e1623e857e8f --- /dev/null +++ b/packages/frontend/editor-ui/src/app/components/DependencyPill.vue @@ -0,0 +1,249 @@ + + + + + + + diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 26cde317ad3b..67fab5ba2a5c 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -19,6 +19,7 @@ import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; import TimeAgo from '@/app/components/TimeAgo.vue'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import ProjectCardBadge from '@/features/collaboration/projects/components/ProjectCardBadge.vue'; +import DependencyPill from '@/app/components/DependencyPill.vue'; import { useI18n } from '@n8n/i18n'; import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@/app/composables/useTelemetry'; @@ -48,6 +49,7 @@ import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp'; import { useWorkflowActivate } from '@/app/composables/useWorkflowActivate'; import { createEventBus } from '@n8n/utils/event-bus'; import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials'; +import { useDependencies } from '@/app/composables/useDependencies'; const WORKFLOW_LIST_ITEM_ACTIONS = { OPEN: 'open', @@ -110,6 +112,7 @@ const route = useRoute(); const telemetry = useTelemetry(); const mcp = useMcp(); const { isEnabled: isDynamicCredentialsEnabled } = useDynamicCredentials(); +const { hasDependencies } = useDependencies(); const uiStore = useUIStore(); const usersStore = useUsersStore(); @@ -306,6 +309,8 @@ const isResolverMissing = computed(() => { ); }); +const workflowHasDependencies = computed(() => hasDependencies(props.data.id)); + async function onClick(event?: KeyboardEvent | PointerEvent) { if (event?.ctrlKey || event?.metaKey) { const route = router.resolve({ @@ -667,6 +672,13 @@ const tags = computed(