From cdc23c666ffd6ed55f019aa24c99efdaf10fad14 Mon Sep 17 00:00:00 2001 From: Robin Braumann <50590409+bjorger@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:23:57 +0000 Subject: [PATCH] feat(editor): Add project selection to Vertex AI credentials (#40667) --- .../credential-options-request.dto.ts | 26 ++ packages/@n8n/api-types/src/dto/index.ts | 1 + .../GoogleVertexAiApi.credentials.ts | 18 ++ .../EmbeddingsGoogleVertex.node.ts | 33 +-- .../LmChatGoogleVertex.node.ts | 33 +-- .../nodes-langchain/utils/google-vertex.ts | 57 +++- .../utils/tests/google-vertex.test.ts | 167 +++++++++-- .../credential-options.controller.test.ts | 19 ++ .../credential-options.service.test.ts | 215 ++++++++++++++ .../__tests__/credentials.service.test.ts | 30 ++ .../credential-options.controller.ts | 42 +++ .../credentials/credential-options.service.ts | 132 +++++++++ .../src/credentials/credentials.service.ts | 34 +-- packages/cli/src/server.ts | 1 + .../src/components/N8nSelect/Select.test.ts | 39 +++ .../src/components/N8nSelect/Select.vue | 22 +- .../frontend/@n8n/i18n/src/locales/en.json | 3 + .../CredentialEdit/CredentialConfig.vue | 6 + .../CredentialEdit/CredentialEdit.test.ts | 109 +++++++ .../CredentialEdit/CredentialEdit.vue | 2 + .../CredentialEdit/CredentialInputs.test.ts | 277 +++++++++++++++++- .../CredentialEdit/CredentialInputs.vue | 11 + .../CredentialEdit/CredentialOptionsInput.vue | 120 ++++++++ .../composables/useCredentialOptions.ts | 112 +++++++ .../credentials/credentials.api.test.ts | 47 +++ .../features/credentials/credentials.api.ts | 30 +- packages/workflow/src/interfaces.ts | 9 + 27 files changed, 1508 insertions(+), 87 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/credentials/credential-options-request.dto.ts create mode 100644 packages/cli/src/credentials/__tests__/credential-options.controller.test.ts create mode 100644 packages/cli/src/credentials/__tests__/credential-options.service.test.ts create mode 100644 packages/cli/src/credentials/credential-options.controller.ts create mode 100644 packages/cli/src/credentials/credential-options.service.ts create mode 100644 packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialOptionsInput.vue create mode 100644 packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOptions.ts diff --git a/packages/@n8n/api-types/src/dto/credentials/credential-options-request.dto.ts b/packages/@n8n/api-types/src/dto/credentials/credential-options-request.dto.ts new file mode 100644 index 00000000000..bba01921a48 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/credentials/credential-options-request.dto.ts @@ -0,0 +1,26 @@ +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; +import { z } from 'zod'; + +import { jsonValueSchema } from '../../schemas/json-value.schema'; +import { Z } from '../../zod-class'; + +const jsonObjectSchema = z.object({}).catchall(jsonValueSchema); +const credentialDataSchema: z.ZodType = z.record( + z.string(), + z.union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.string()), + jsonObjectSchema, + z.array(jsonObjectSchema), + ]), +); + +export class CredentialOptionsRequestDto extends Z.class({ + type: z.string().min(1), + data: credentialDataSchema, + propertyName: z.string().min(1), + filter: z.string().optional(), + paginationToken: z.string().optional(), +}) {} diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 3887b69127f..b0debbb4a8f 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -168,6 +168,7 @@ export { SourceControlPullResponsePublicDto } from './source-control/source-cont export { CreateCredentialDto } from './credentials/create-credential.dto'; export { TestCredentialRequestDto } from './credentials/test-credential-request.dto'; +export { CredentialOptionsRequestDto } from './credentials/credential-options-request.dto'; export { CredentialPublicDto, CredentialListPublicDto, diff --git a/packages/@n8n/nodes-langchain/credentials/GoogleVertexAiApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/GoogleVertexAiApi.credentials.ts index a11061b4e95..4882fda5ca9 100644 --- a/packages/@n8n/nodes-langchain/credentials/GoogleVertexAiApi.credentials.ts +++ b/packages/@n8n/nodes-langchain/credentials/GoogleVertexAiApi.credentials.ts @@ -1,5 +1,7 @@ import type { ICredentialType, INodeProperties, Icon } from 'n8n-workflow'; +import { GOOGLE_VERTEX_CUSTOM_PROJECT, searchGoogleProjects } from '../utils/google-vertex'; + export class GoogleVertexAiApi implements ICredentialType { name = 'googleVertexAiApi'; @@ -12,6 +14,19 @@ export class GoogleVertexAiApi implements ICredentialType { icon: Icon = 'file:icons/google.svg'; properties: INodeProperties[] = [ + { + displayName: 'Project', + name: 'project', + type: 'options', + options: [{ name: 'Custom', value: GOOGLE_VERTEX_CUSTOM_PROJECT }], + default: GOOGLE_VERTEX_CUSTOM_PROJECT, + required: true, + description: 'Select a Google Cloud project, or select Custom to enter a project ID', + typeOptions: { + loadOptionsMethod: 'gcpProjectsList', + loadOptionsDependsOn: ['email', 'privateKey'], + }, + }, { displayName: 'Project ID', name: 'projectId', @@ -20,6 +35,7 @@ export class GoogleVertexAiApi implements ICredentialType { required: true, placeholder: 'my-project-id', description: 'Google Cloud project to use with Vertex AI', + displayOptions: { show: { project: [GOOGLE_VERTEX_CUSTOM_PROJECT] } }, }, // Hide inherited settings that the Vertex AI client does not use. { @@ -65,4 +81,6 @@ export class GoogleVertexAiApi implements ICredentialType { default: '', }, ]; + + methods = { loadOptions: { gcpProjectsList: searchGoogleProjects } }; } diff --git a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts index 62f7a08fad3..742de6dc52c 100644 --- a/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node.ts @@ -1,5 +1,3 @@ -import { ProjectsClient } from '@google-cloud/resource-manager'; -import { getGoogleServiceAccountCredentials } from 'n8n-nodes-base/google-service-account'; import { VertexAIEmbeddings } from '@langchain/google-vertexai'; import { logWrapper, getConnectionHintNoticeField } from '@n8n/ai-utilities'; import { NodeConnectionTypes } from 'n8n-workflow'; @@ -18,32 +16,23 @@ import { googleVertexCredentials, googleVertexAiCredentialTest, resolveGoogleVertexCredentials, + searchGoogleProjects, } from '@utils/google-vertex'; export class EmbeddingsGoogleVertex implements INodeType { methods = { credentialTest: { googleVertexAiCredentialTest }, listSearch: { - async gcpProjectsList(this: ILoadOptionsFunctions) { - const results: Array<{ name: string; value: string }> = []; - - const credentials = await this.getCredentials('googleApi'); - const client = new ProjectsClient({ - credentials: getGoogleServiceAccountCredentials(credentials), - }); - - const [projects] = await client.searchProjects(); - - for (const project of projects) { - if (project.projectId) { - results.push({ - name: project.displayName ?? project.projectId, - value: project.projectId, - }); - } - } - - return { results }; + async gcpProjectsList( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ) { + return await searchGoogleProjects( + await this.getCredentials('googleApi'), + filter, + paginationToken, + ); }, }, }; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts index 2ad18be1db7..bd73e33eb1f 100644 --- a/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node.ts @@ -1,5 +1,3 @@ -import { ProjectsClient } from '@google-cloud/resource-manager'; -import { getGoogleServiceAccountCredentials } from 'n8n-nodes-base/google-service-account'; import type { GoogleAISafetySetting } from '@langchain/google-common'; import { ChatVertexAI, type ChatVertexAIInput } from '@langchain/google-vertexai'; import { @@ -31,6 +29,7 @@ import { googleVertexCredentials, googleVertexAiCredentialTest, resolveGoogleVertexCredentials, + searchGoogleProjects, } from '@utils/google-vertex'; function errorDescriptionMapper(error: NodeError) { @@ -121,26 +120,16 @@ export class LmChatGoogleVertex implements INodeType { methods = { credentialTest: { googleVertexAiCredentialTest }, listSearch: { - async gcpProjectsList(this: ILoadOptionsFunctions) { - const results: Array<{ name: string; value: string }> = []; - - const credentials = await this.getCredentials('googleApi'); - const client = new ProjectsClient({ - credentials: getGoogleServiceAccountCredentials(credentials), - }); - - const [projects] = await client.searchProjects(); - - for (const project of projects) { - if (project.projectId) { - results.push({ - name: project.displayName ?? project.projectId, - value: project.projectId, - }); - } - } - - return { results }; + async gcpProjectsList( + this: ILoadOptionsFunctions, + filter?: string, + paginationToken?: string, + ) { + return await searchGoogleProjects( + await this.getCredentials('googleApi'), + filter, + paginationToken, + ); }, }, }; diff --git a/packages/@n8n/nodes-langchain/utils/google-vertex.ts b/packages/@n8n/nodes-langchain/utils/google-vertex.ts index 6196ba15646..f34fa6f16c2 100644 --- a/packages/@n8n/nodes-langchain/utils/google-vertex.ts +++ b/packages/@n8n/nodes-langchain/utils/google-vertex.ts @@ -5,16 +5,20 @@ import { } from 'n8n-nodes-base/google-service-account'; import { NodeOperationError, + type ICredentialDataDecryptedObject, type ICredentialsDecrypted, type ICredentialTestFunctions, type INodeCredentialDescription, type INodeCredentialTestResult, + type INodeListSearchResult, type INodeProperties, type ISupplyDataFunctions, } from 'n8n-workflow'; import { resolveVertexLocation } from '../nodes/llms/gemini-common/vertex-location'; +export const GOOGLE_VERTEX_CUSTOM_PROJECT = '__custom__'; + export const googleVertexCredentials: INodeCredentialDescription[] = [ { name: 'googleApi', @@ -41,6 +45,11 @@ export const googleVertexAuthentication: INodeProperties = { default: 'googleApi', }; +function getGoogleVertexProjectId(credentials: ICredentialDataDecryptedObject) { + const project = credentials.project ?? GOOGLE_VERTEX_CUSTOM_PROJECT; + return project === GOOGLE_VERTEX_CUSTOM_PROJECT ? credentials.projectId : project; +} + export async function resolveGoogleVertexCredentials( context: ISupplyDataFunctions, itemIndex: number, @@ -50,7 +59,7 @@ export async function resolveGoogleVertexCredentials( const credentials = await context.getCredentials(credentialType); let projectId: unknown; if (credentialType === 'googleVertexAiApi') { - projectId = credentials.projectId; + projectId = getGoogleVertexProjectId(credentials); } else { projectId = context.getNodeParameter('projectId', itemIndex, '', { extractValue: true }); } @@ -77,8 +86,9 @@ export async function googleVertexAiCredentialTest( this: ICredentialTestFunctions, credential: ICredentialsDecrypted, ): Promise { - const data = credential.data; - if (typeof data?.projectId !== 'string' || !data.projectId.trim()) { + const data = credential.data ?? {}; + const projectId = getGoogleVertexProjectId(data); + if (typeof projectId !== 'string' || !projectId.trim()) { return { status: 'Error', message: 'Select or enter a Google Cloud project ID.' }; } @@ -99,7 +109,7 @@ export async function googleVertexAiCredentialTest( // Check project access without running a model or requiring the Resource Manager API. await this.helpers.request({ method: 'GET', - uri: `https://aiplatform.googleapis.com/v1/projects/${encodeURIComponent(data.projectId.trim())}/locations`, + uri: `https://aiplatform.googleapis.com/v1/projects/${encodeURIComponent(projectId.trim())}/locations`, headers: { Authorization: `Bearer ${token.access_token}` }, qs: { pageSize: 1 }, json: true, @@ -114,3 +124,42 @@ export async function googleVertexAiCredentialTest( }; } } + +export async function searchGoogleProjects( + credentials: ICredentialDataDecryptedObject, + filter?: string, + paginationToken?: string, +): Promise { + const { ProjectsClient } = await import('@google-cloud/resource-manager'); + const client = new ProjectsClient({ + credentials: getGoogleServiceAccountCredentials(credentials), + }); + try { + const trimmedFilter = filter?.trim(); + let query: string | undefined; + if (trimmedFilter) { + const prefix = JSON.stringify(`${trimmedFilter}*`); + query = `displayName:${prefix} projectId:${prefix}`; + } + const [projects, nextPage] = await client.searchProjects( + { pageToken: paginationToken, ...(query ? { query } : {}) }, + { autoPaginate: false }, + ); + return { + results: projects.flatMap((project) => { + if (!project.projectId) return []; + return [ + { + name: project.displayName + ? `${project.displayName} (${project.projectId})` + : project.projectId, + value: project.projectId, + }, + ]; + }), + paginationToken: nextPage?.pageToken ?? undefined, + }; + } finally { + await client.close(); + } +} diff --git a/packages/@n8n/nodes-langchain/utils/tests/google-vertex.test.ts b/packages/@n8n/nodes-langchain/utils/tests/google-vertex.test.ts index 425902d1a7e..b8eedbd6c4d 100644 --- a/packages/@n8n/nodes-langchain/utils/tests/google-vertex.test.ts +++ b/packages/@n8n/nodes-langchain/utils/tests/google-vertex.test.ts @@ -6,18 +6,126 @@ import { type ICredentialTestFunctions, type INode, type INodeTypes, + type ISupplyDataFunctions, } from 'n8n-workflow'; import { mock } from 'vitest-mock-extended'; import { EmbeddingsGoogleVertex } from '../../nodes/embeddings/EmbeddingsGoogleVertex/EmbeddingsGoogleVertex.node'; import { LmChatGoogleVertex } from '../../nodes/llms/LmChatGoogleVertex/LmChatGoogleVertex.node'; -import { googleVertexAiCredentialTest } from '../google-vertex'; +import { + googleVertexAiCredentialTest, + resolveGoogleVertexCredentials, + searchGoogleProjects, +} from '../google-vertex'; vi.mock('n8n-nodes-base/google-service-account', async (importOriginal) => ({ ...(await importOriginal()), getGoogleAccessToken: vi.fn(), })); +const { searchProjects, close, ProjectsClient } = vi.hoisted(() => { + const searchProjects = vi.fn(); + const close = vi.fn(); + return { + searchProjects, + close, + ProjectsClient: vi.fn( + class { + searchProjects = searchProjects; + close = close; + }, + ), + }; +}); + +vi.mock('@google-cloud/resource-manager', () => ({ ProjectsClient })); + +describe('Google Vertex credential project', () => { + it.each([ + { project: undefined, projectId: 'saved-project', expected: 'saved-project' }, + { project: '__custom__', projectId: 'manual-project', expected: 'manual-project' }, + { project: 'selected-project', projectId: 'old-manual-project', expected: 'selected-project' }, + ])('uses project $expected for selector $project', async ({ project, projectId, expected }) => { + const context = mock(); + context.getCredentials.mockResolvedValue({ + email: 'service@example.com', + privateKey: 'test-key', + region: 'global', + ...(project === undefined ? {} : { project }), + projectId, + }); + context.getNodeParameter.mockImplementation((name) => + name === 'authentication' ? 'googleVertexAiApi' : '', + ); + await expect(resolveGoogleVertexCredentials(context, 0)).resolves.toMatchObject({ + projectId: expected, + }); + }); +}); + +describe('Google project discovery', () => { + const credentials = Object.freeze({ + email: ' service@owner-project.iam.gserviceaccount.com ', + privateKey: '-----BEGIN PRIVATE KEY----- key -----END PRIVATE KEY-----', + region: 'eu', + }); + + beforeEach(() => vi.clearAllMocks()); + + it.each([ + { filter: undefined, expectedRequest: { pageToken: 'current-page' } }, + { filter: '', expectedRequest: { pageToken: 'current-page' } }, + { filter: ' \t\n ', expectedRequest: { pageToken: 'current-page' } }, + { + filter: 'target', + expectedRequest: { + pageToken: 'current-page', + query: 'displayName:"target*" projectId:"target*"', + }, + }, + { + filter: ' Target "project"\\folder ', + expectedRequest: { + pageToken: 'current-page', + query: + 'displayName:"Target \\"project\\"\\\\folder*" projectId:"Target \\"project\\"\\\\folder*"', + }, + }, + ])('returns projects and pagination for filter $filter', async ({ filter, expectedRequest }) => { + searchProjects.mockResolvedValue([ + [ + { displayName: 'Target project', projectId: 'target-project' }, + { projectId: 'another-project' }, + { displayName: 'Missing ID' }, + ], + { pageToken: 'next-page' }, + ]); + + await expect(searchGoogleProjects(credentials, filter, 'current-page')).resolves.toEqual({ + results: [ + { name: 'Target project (target-project)', value: 'target-project' }, + { name: 'another-project', value: 'another-project' }, + ], + paginationToken: 'next-page', + }); + expect(ProjectsClient).toHaveBeenCalledWith({ + credentials: { + client_email: 'service@owner-project.iam.gserviceaccount.com', + private_key: '-----BEGIN PRIVATE KEY-----\nkey\n-----END PRIVATE KEY-----', + }, + }); + expect(searchProjects).toHaveBeenCalledWith(expectedRequest, { autoPaginate: false }); + expect(close).toHaveBeenCalled(); + }); + + it('closes the client when project discovery fails', async () => { + searchProjects.mockRejectedValue(new Error('Permission denied')); + + await expect(searchGoogleProjects(credentials)).rejects.toThrow('Permission denied'); + expect(close).toHaveBeenCalled(); + }); +}); + describe.each([new LmChatGoogleVertex(), new EmbeddingsGoogleVertex()])( '$description.displayName credential selection', (node) => { @@ -76,14 +184,17 @@ describe('googleVertexAiCredentialTest', () => { const context = mock({ helpers: mock({ request }), }); - const credential = mock({ + const credential: ICredentialsDecrypted = { + id: 'vertex-credential', + name: 'Google Vertex AI', + type: 'googleVertexAiApi', data: { email: 'test@example.com', privateKey: 'test-private-key', projectId: ' test-project ', delegatedEmail: 'unused@example.com', }, - }); + }; beforeEach(() => { vi.resetAllMocks(); @@ -91,23 +202,41 @@ describe('googleVertexAiCredentialTest', () => { request.mockResolvedValue({ locations: [] }); }); - it('checks the credential project with Vertex scopes and service account authentication', async () => { - const result = await googleVertexAiCredentialTest.call(context, credential); + it.each([ + { project: undefined, projectId: ' test-project ', expected: 'test-project' }, + { project: '__custom__', projectId: ' manual-project ', expected: 'manual-project' }, + { + project: ' selected-project ', + projectId: 'old-manual-project', + expected: 'selected-project', + }, + ])( + 'checks project $expected with Vertex scopes and service account authentication', + async ({ project, projectId, expected }) => { + const result = await googleVertexAiCredentialTest.call(context, { + ...credential, + data: { + ...credential.data, + ...(project === undefined ? {} : { project }), + projectId, + }, + }); - expect(result.status).toBe('OK'); - expect(getGoogleAccessToken).toHaveBeenCalledWith( - { email: 'test@example.com', privateKey: 'test-private-key' }, - 'vertex', - ); - expect(request).toHaveBeenCalledWith( - expect.objectContaining({ - method: 'GET', - uri: 'https://aiplatform.googleapis.com/v1/projects/test-project/locations', - headers: { Authorization: 'Bearer test-access-token' }, - qs: { pageSize: 1 }, - }), - ); - }); + expect(result.status).toBe('OK'); + expect(getGoogleAccessToken).toHaveBeenCalledWith( + { email: 'test@example.com', privateKey: 'test-private-key' }, + 'vertex', + ); + expect(request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + uri: `https://aiplatform.googleapis.com/v1/projects/${expected}/locations`, + headers: { Authorization: 'Bearer test-access-token' }, + qs: { pageSize: 1 }, + }), + ); + }, + ); it('reports a project error even when the service account key is valid', async () => { request.mockRejectedValue(new Error('Project not found')); diff --git a/packages/cli/src/credentials/__tests__/credential-options.controller.test.ts b/packages/cli/src/credentials/__tests__/credential-options.controller.test.ts new file mode 100644 index 00000000000..8924ef3402c --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credential-options.controller.test.ts @@ -0,0 +1,19 @@ +import { ControllerRegistryMetadata, type Controller } from '@n8n/decorators'; +import { Container } from '@n8n/di'; + +import { CredentialOptionsController } from '../credential-options.controller'; + +describe('Credential resource locator route access', () => { + const { routes } = Container.get(ControllerRegistryMetadata).getControllerMetadata( + CredentialOptionsController as Controller, + ); + + it.each([ + ['lookupProjectDraft', 'credential:create', false], + ['lookupInstanceDraft', 'credential:manageInstance', true], + ['lookupStored', 'credential:read', false], + ])('%s requires %s', (handler, scope, globalOnly) => { + expect(routes.get(handler)?.accessScope).toEqual({ scope, globalOnly }); + expect(routes.get(handler)?.skipAuth).not.toBe(true); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credential-options.service.test.ts b/packages/cli/src/credentials/__tests__/credential-options.service.test.ts new file mode 100644 index 00000000000..c03523ffa92 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/credential-options.service.test.ts @@ -0,0 +1,215 @@ +import type { CredentialOptionsRequestDto } from '@n8n/api-types'; +import type { CredentialsFinderService } from '@n8n/backend-services'; +import { GLOBAL_MEMBER_ROLE, GLOBAL_OWNER_ROLE, type CredentialsEntity, type User } from '@n8n/db'; +import type { + ICredentialType, + IWorkflowExecuteAdditionalData, + ProjectSharingData, +} from 'n8n-workflow'; +import { mock } from 'vitest-mock-extended'; + +import type { CredentialTypes } from '@/credential-types'; +import type { CredentialsHelper } from '@/credentials-helper'; +import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; +import type { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; +import { userHasScopes } from '@/permissions.ee/check-access'; +import { getBase } from '@/workflow-execute-additional-data'; + +import { CredentialOptionsService } from '../credential-options.service'; +import type { CredentialsService } from '../credentials.service'; + +vi.mock('@/permissions.ee/check-access', () => ({ userHasScopes: vi.fn() })); +vi.mock('@/workflow-execute-additional-data', () => ({ getBase: vi.fn() })); + +describe('CredentialOptionsService', () => { + const credentialTypes = mock(); + const helper = mock(); + const credentials = mock(); + const finder = mock(); + const externalSecretsConfig = mock(); + const secretProviderAccess = mock(); + const service = new CredentialOptionsService( + credentialTypes, + helper, + credentials, + finder, + externalSecretsConfig, + secretProviderAccess, + ); + const user = mock({ id: 'user-id', role: GLOBAL_OWNER_ROLE }); + const stored = mock({ + id: 'saved-id', + name: 'Vertex', + type: 'googleVertexAiApi', + isManaged: false, + }); + const loader = vi.fn(); + const homeProject = mock({ id: 'owning-project' }); + const type: ICredentialType = { + name: 'googleVertexAiApi', + displayName: 'Google Vertex AI', + properties: [ + { + name: 'project', + displayName: 'Project', + type: 'options', + options: [{ name: 'Custom', value: '__custom__' }], + default: '__custom__', + typeOptions: { loadOptionsMethod: 'projects' }, + }, + ], + methods: { loadOptions: { projects: loader } }, + }; + const request: CredentialOptionsRequestDto = { + type: type.name, + propertyName: 'project', + data: { email: 'service@example.com', privateKey: 'draft-key' }, + paginationToken: 'page-two', + }; + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(userHasScopes).mockResolvedValue(true); + vi.mocked(getBase).mockResolvedValue(mock()); + credentialTypes.getByName.mockReturnValue(type); + helper.getCredentialsProperties.mockReturnValue(type.properties); + helper.applyDefaultsAndOverwrites.mockImplementation(async (_, data) => data); + finder.findCredentialForUser.mockResolvedValue(stored); + credentials.prepareCredentialsForUse.mockResolvedValue({ + credentials: { + id: stored.id, + name: stored.name, + type: stored.type, + data: request.data, + homeProject, + }, + storedData: request.data, + }); + loader.mockResolvedValue({ results: [{ name: 'Target project', value: 'target-project' }] }); + }); + + it('loads an unsaved draft with its n8n project context and prepared values', async () => { + helper.applyDefaultsAndOverwrites.mockResolvedValue({ + ...request.data, + privateKey: 'overwritten-key', + region: 'global', + }); + await expect(service.lookupDraft(user, request, 'n8n-project')).resolves.toEqual({ + results: [{ name: 'Target project', value: 'target-project' }], + }); + expect(getBase).toHaveBeenCalledWith({ userId: user.id, projectId: 'n8n-project' }); + expect(loader).toHaveBeenCalledWith( + { email: 'service@example.com', privateKey: 'overwritten-key', region: 'global' }, + undefined, + 'page-two', + ); + expect(credentials.enforceCredentialUse).toHaveBeenCalledWith( + { id: '', type: type.name }, + { kind: 'user', user }, + 'n8n-project', + ); + }); + + it('loads a stored credential with the restored data and server-owned project', async () => { + await service.lookupStored(user, stored.id, { ...request, data: { privateKey: '***' } }); + expect(getBase).toHaveBeenCalledWith({ userId: user.id, projectId: 'owning-project' }); + expect(loader).toHaveBeenCalledWith(request.data, undefined, 'page-two'); + }); + + it('uses stored values for managed credentials', async () => { + finder.findCredentialForUser.mockResolvedValue({ ...stored, isManaged: true }); + await service.lookupStored(user, stored.id, request); + expect(credentials.prepareCredentialsForUse).toHaveBeenCalledWith({ + storedCredential: { ...stored, isManaged: true }, + user, + credentialsToUse: undefined, + }); + }); + + it('rejects drafts outside the allowed project before a provider call', async () => { + vi.mocked(userHasScopes).mockResolvedValue(false); + await expect(service.lookupDraft(user, request, 'other-project')).rejects.toThrow(); + expect(loader).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'enforces instance credential access for an owner: %s', + async (isOwner) => { + const actor = mock({ + id: 'actor', + role: isOwner ? GLOBAL_OWNER_ROLE : GLOBAL_MEMBER_ROLE, + }); + const result = service.lookupDraft(actor, request, null); + if (isOwner) { + await expect(result).resolves.toMatchObject({ results: [{ value: 'target-project' }] }); + expect(credentials.validateInstanceCredentialData).toHaveBeenCalledWith(request.data); + } else { + await expect(result).rejects.toThrow(); + expect(loader).not.toHaveBeenCalled(); + } + }, + ); + + it.each(['missing', 'wrong-type', 'blocked'])( + 'rejects a %s stored credential before a provider call', + async (reason) => { + if (reason === 'missing') finder.findCredentialForUser.mockResolvedValue(null); + if (reason === 'wrong-type') + finder.findCredentialForUser.mockResolvedValue({ ...stored, type: 'googleApi' }); + if (reason === 'blocked') + credentials.prepareCredentialsForUse.mockRejectedValue( + new Error('Credential use is blocked'), + ); + await expect(service.lookupStored(user, stored.id, request)).rejects.toThrow(); + expect(loader).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'checks secret-reference permissions when stored values change: %s', + async (isChanged) => { + vi.mocked(userHasScopes).mockResolvedValue(false); + secretProviderAccess.isProviderAvailableInProject.mockImplementation( + async (providerKey, projectId) => providerKey === 'vault' && projectId === homeProject.id, + ); + const storedData = { ...request.data, privateKey: '={{ $secrets.vault.existingKey }}' }; + const data = { + ...storedData, + privateKey: isChanged ? '={{ $secrets.vault.newKey }}' : storedData.privateKey, + }; + credentials.prepareCredentialsForUse.mockResolvedValue({ + credentials: { + id: stored.id, + name: stored.name, + type: stored.type, + homeProject, + data, + }, + storedData, + }); + const result = service.lookupStored(user, stored.id, { ...request, data }); + if (isChanged) { + await expect(result).rejects.toThrow('permissions'); + expect(loader).not.toHaveBeenCalled(); + } else { + await expect(result).resolves.toEqual({ + results: [{ name: 'Target project', value: 'target-project' }], + }); + } + }, + ); + + it('rejects fields without a declared list callback', async () => { + await expect( + service.lookupDraft(user, { ...request, propertyName: 'privateKey' }, 'n8n-project'), + ).rejects.toThrow('does not support'); + expect(loader).not.toHaveBeenCalled(); + }); + + it('returns a safe error when discovery fails', async () => { + loader.mockRejectedValue(new Error('Provider failed with private key draft-key')); + await expect(service.lookupDraft(user, request, 'n8n-project')).rejects.toThrow( + 'Could not load the list. Try again or enter an ID.', + ); + }); +}); diff --git a/packages/cli/src/credentials/__tests__/credentials.service.test.ts b/packages/cli/src/credentials/__tests__/credentials.service.test.ts index 7330bb3b661..a037bd3c88b 100644 --- a/packages/cli/src/credentials/__tests__/credentials.service.test.ts +++ b/packages/cli/src/credentials/__tests__/credentials.service.test.ts @@ -2258,6 +2258,36 @@ describe('CredentialsService', () => { }); }); + describe('prepareCredentialsForUse', () => { + it('restores a masked key and preserves the original data for secret permission checks', async () => { + const storedCredential = mock({ + id: 'credential-id', + name: 'Vertex', + type: 'googleVertexAiApi', + }); + const storedData = { email: 'service@example.com', privateKey: 'stored-key' }; + vi.spyOn(service, 'decrypt').mockResolvedValue(storedData); + vi.spyOn(service, 'replaceCredentialContentsForSharee').mockResolvedValue(undefined); + vi.spyOn(service, 'getCredentialTypeProperties').mockReturnValue([]); + const result = await service.prepareCredentialsForUse({ + storedCredential, + user: ownerUser, + credentialsToUse: { + id: storedCredential.id, + name: storedCredential.name, + type: storedCredential.type, + data: { email: 'edited@example.com', privateKey: CREDENTIAL_BLANKING_VALUE }, + }, + }); + + expect(result.credentials.data).toEqual({ + email: 'edited@example.com', + privateKey: 'stored-key', + }); + expect(result.storedData).toEqual(storedData); + }); + }); + describe('testWithCredentials', () => { it('refuses a stored credential of a blocked type before the tester runs', async () => { credentialsFinderService.findCredentialForUser.mockResolvedValue( diff --git a/packages/cli/src/credentials/credential-options.controller.ts b/packages/cli/src/credentials/credential-options.controller.ts new file mode 100644 index 00000000000..0bce089febf --- /dev/null +++ b/packages/cli/src/credentials/credential-options.controller.ts @@ -0,0 +1,42 @@ +import { CredentialOptionsRequestDto } from '@n8n/api-types'; +import { AuthenticatedRequest } from '@n8n/db'; +import { Body, GlobalScope, Param, Post, ProjectScope, RestController } from '@n8n/decorators'; + +import { CredentialOptionsService } from './credential-options.service'; + +@RestController('/credentials') +export class CredentialOptionsController { + constructor(private readonly credentialOptionsService: CredentialOptionsService) {} + + @Post('/options/projects/:projectId') + @ProjectScope('credential:create') + async lookupProjectDraft( + req: AuthenticatedRequest, + _res: unknown, + @Param('projectId') projectId: string, + @Body request: CredentialOptionsRequestDto, + ) { + return await this.credentialOptionsService.lookupDraft(req.user, request, projectId); + } + + @Post('/options/instance') + @GlobalScope('credential:manageInstance') + async lookupInstanceDraft( + req: AuthenticatedRequest, + _res: unknown, + @Body request: CredentialOptionsRequestDto, + ) { + return await this.credentialOptionsService.lookupDraft(req.user, request, null); + } + + @Post('/:credentialId/options') + @ProjectScope('credential:read') + async lookupStored( + req: AuthenticatedRequest, + _res: unknown, + @Param('credentialId') credentialId: string, + @Body request: CredentialOptionsRequestDto, + ) { + return await this.credentialOptionsService.lookupStored(req.user, credentialId, request); + } +} diff --git a/packages/cli/src/credentials/credential-options.service.ts b/packages/cli/src/credentials/credential-options.service.ts new file mode 100644 index 00000000000..cdc1cac59c6 --- /dev/null +++ b/packages/cli/src/credentials/credential-options.service.ts @@ -0,0 +1,132 @@ +import type { CredentialOptionsRequestDto } from '@n8n/api-types'; +import { CredentialsFinderService } from '@n8n/backend-services'; +import type { User } from '@n8n/db'; +import { Service } from '@n8n/di'; +import { BadRequestError, ForbiddenError } from '@n8n/errors'; +import { hasGlobalScope } from '@n8n/permissions'; +import type { ICredentialDataDecryptedObject } from 'n8n-workflow'; + +import { CredentialTypes } from '@/credential-types'; +import { CredentialsHelper } from '@/credentials-helper'; +import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; +import { SecretsProviderAccessCheckService } from '@/modules/external-secrets.ee/secret-provider-access-check.service.ee'; +import { userHasScopes } from '@/permissions.ee/check-access'; +import { getBase } from '@/workflow-execute-additional-data'; + +import { CredentialsService } from './credentials.service'; +import { + validateAccessToReferencedSecretProviders, + validateExternalSecretsPermissions, +} from './validation'; + +@Service() +export class CredentialOptionsService { + constructor( + private readonly credentialTypes: CredentialTypes, + private readonly credentialsHelper: CredentialsHelper, + private readonly credentialsService: CredentialsService, + private readonly credentialsFinder: CredentialsFinderService, + private readonly externalSecretsConfig: ExternalSecretsConfig, + private readonly secretProviderAccess: SecretsProviderAccessCheckService, + ) {} + + async lookupDraft(user: User, request: CredentialOptionsRequestDto, projectId: string | null) { + const allowed = + projectId === null + ? hasGlobalScope(user, 'credential:manageInstance') + : await userHasScopes(user, ['credential:create'], false, { projectId }); + if (!allowed) throw new ForbiddenError(); + await this.credentialsService.enforceCredentialUse( + { id: '', type: request.type }, + { kind: 'user', user }, + projectId, + ); + await this.validateExternalSecrets(user, request.data, projectId); + return await this.load(user, request, request.data, projectId); + } + + async lookupStored(user: User, credentialId: string, request: CredentialOptionsRequestDto) { + const storedCredential = await this.credentialsFinder.findCredentialForUser( + credentialId, + user, + ['credential:read'], + { includeInstanceCredentials: true }, + ); + if (!storedCredential) throw new ForbiddenError(); + if (storedCredential.type !== request.type) { + throw new BadRequestError('The credential type does not match the stored credential'); + } + const { credentials, storedData } = await this.credentialsService.prepareCredentialsForUse({ + storedCredential, + user, + credentialsToUse: storedCredential.isManaged + ? undefined + : { id: credentialId, name: storedCredential.name, type: request.type, data: request.data }, + }); + const projectId = credentials.homeProject?.id ?? null; + const data = credentials.data ?? {}; + await this.validateExternalSecrets(user, data, projectId, storedData); + return await this.load(user, request, data, projectId); + } + + private async validateExternalSecrets( + user: User, + data: ICredentialDataDecryptedObject, + projectId: string | null, + storedData?: ICredentialDataDecryptedObject, + ) { + if (projectId === null) { + this.credentialsService.validateInstanceCredentialData(data); + return; + } + await validateExternalSecretsPermissions({ + user, + projectId, + dataToSave: data, + decryptedExistingData: storedData, + }); + if (this.externalSecretsConfig.externalSecretsForProjects) { + await validateAccessToReferencedSecretProviders( + projectId, + data, + this.secretProviderAccess, + 'create', + ); + } + } + + private async load( + user: User, + request: CredentialOptionsRequestDto, + data: ICredentialDataDecryptedObject, + projectId: string | null, + ) { + const credentialType = this.credentialTypes.getByName(request.type); + const property = this.credentialsHelper + .getCredentialsProperties(request.type) + .find((field) => field.name === request.propertyName); + const methodName = property?.typeOptions?.loadOptionsMethod; + const loader = + methodName && Object.hasOwn(credentialType.methods?.loadOptions ?? {}, methodName) + ? credentialType.methods?.loadOptions?.[methodName] + : undefined; + if (property?.type !== 'options' || !loader) { + throw new BadRequestError('This credential field does not support loading a list.'); + } + try { + const additionalData = await getBase({ userId: user.id, projectId: projectId ?? undefined }); + const resolvedData = await this.credentialsHelper.applyDefaultsAndOverwrites( + additionalData, + data, + request.type, + 'internal', + undefined, + undefined, + ); + return await loader(resolvedData, request.filter, request.paginationToken); + } catch { + // Provider errors can contain credential data. + throw new BadRequestError('Could not load the list. Try again or enter an ID.'); + } + } +} diff --git a/packages/cli/src/credentials/credentials.service.ts b/packages/cli/src/credentials/credentials.service.ts index b53bff437f8..6a34154ad46 100644 --- a/packages/cli/src/credentials/credentials.service.ts +++ b/packages/cli/src/credentials/credentials.service.ts @@ -1047,7 +1047,7 @@ export class CredentialsService { return await this.decrypt(credential, true); } - private async enforceCredentialUse( + async enforceCredentialUse( credential: Pick, actor: PolicyActor, projectId: string | null, @@ -1538,7 +1538,7 @@ export class CredentialsService { throw new CredentialNotFoundError(credentialId); } - const credentials = await this.prepareCredentialsForTest({ storedCredential, user }); + const { credentials } = await this.prepareCredentialsForUse({ storedCredential, user }); return await this.test(user.id, credentials); } @@ -1560,10 +1560,10 @@ export class CredentialsService { throw new CredentialNotFoundError(credentials.id); } - const mergedCredentials = await this.prepareCredentialsForTest({ + const { credentials: mergedCredentials } = await this.prepareCredentialsForUse({ storedCredential, user, - credentialsToTest: storedCredential.isManaged ? undefined : credentials, + credentialsToUse: storedCredential.isManaged ? undefined : credentials, }); return await this.test(user.id, mergedCredentials); @@ -2438,24 +2438,24 @@ export class CredentialsService { } /** - * Build credentials payload ready to pass to credential testing. + * Prepare stored credentials for a provider request. * - * - If `credentialsToTest` is not provided, uses stored decrypted credential data. - * - If `credentialsToTest` is provided, normalizes it for testing: + * - If `credentialsToUse` is not provided, uses stored decrypted credential data. + * - If `credentialsToUse` is provided, normalizes the draft: * - fills payload data for sharees when needed * - restores redacted values from stored decrypted data */ - private async prepareCredentialsForTest({ + async prepareCredentialsForUse({ storedCredential, user, - credentialsToTest, + credentialsToUse, }: { storedCredential: CredentialsEntity; user: User; - credentialsToTest?: ICredentialsDecrypted; - }): Promise { - // The tester picks its test from the posted type, so it must match the stored secrets. - if (credentialsToTest && credentialsToTest.type !== storedCredential.type) { + credentialsToUse?: ICredentialsDecrypted; + }): Promise<{ credentials: ICredentialsDecrypted; storedData: ICredentialDataDecryptedObject }> { + // Provider callbacks must use the type that owns the stored secrets. + if (credentialsToUse && credentialsToUse.type !== storedCredential.type) { throw new BadRequestError('The credential type does not match the stored credential'); } @@ -2466,8 +2466,8 @@ export class CredentialsService { { kind: 'user', user }, owningProject?.id ?? null, ); - const mergedCredentials: ICredentialsDecrypted = credentialsToTest - ? deepCopy(credentialsToTest) + const mergedCredentials: ICredentialsDecrypted = credentialsToUse + ? deepCopy(credentialsToUse) : { id: storedCredential.id, name: storedCredential.name, @@ -2488,7 +2488,7 @@ export class CredentialsService { }; } - if (credentialsToTest) { + if (credentialsToUse) { await this.replaceCredentialContentsForSharee( user, storedCredential, @@ -2505,6 +2505,6 @@ export class CredentialsService { } } - return mergedCredentials; + return { credentials: mergedCredentials, storedData: decryptedData }; } } diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index f7bbde1df62..b6412840e97 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -59,6 +59,7 @@ import '@/controllers/workflow-statistics.controller'; import '@/controllers/api-keys.controller'; import '@/controllers/security-settings.controller'; import '@/credentials/credentials.controller'; +import '@/credentials/credential-options.controller'; import '@/events/events.controller'; import '@/executions/executions.controller'; import '@/node-execution/ephemeral-node-executor'; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.test.ts index b2c89cf11d9..0699c5191c1 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.test.ts @@ -27,6 +27,45 @@ describe('components', () => { expect(wrapper.html()).toMatchSnapshot(); }); + it('should update and remove ARIA attributes on the native combobox', async () => { + const host = defineComponent({ + components: { N8nSelect }, + props: { controlAttrs: Object }, + template: '', + }); + const { getByRole, rerender } = render(host, { + props: { + controlAttrs: { + 'aria-invalid': true, + 'aria-describedby': 'project-error', + 'aria-busy': true, + }, + }, + }); + const input = getByRole('combobox'); + expect(input.tagName).toBe('INPUT'); + await waitFor(() => expect(input).toHaveAttribute('id', 'project')); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(input).toHaveAttribute('aria-describedby', 'project-error'); + expect(input).toHaveAttribute('aria-busy', 'true'); + + await rerender({ + controlAttrs: { + 'aria-invalid': false, + 'aria-describedby': 'project-help', + 'aria-busy': false, + }, + }); + expect(input).toHaveAttribute('aria-invalid', 'false'); + expect(input).toHaveAttribute('aria-describedby', 'project-help'); + expect(input).toHaveAttribute('aria-busy', 'false'); + + await rerender({ controlAttrs: { 'aria-invalid': undefined, 'aria-describedby': null } }); + expect(input).not.toHaveAttribute('aria-invalid'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-busy'); + }); + it('should select an option', async () => { const n8nSelectTestComponent = defineComponent({ props: { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.vue b/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.vue index b942170533a..40426d1b97a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nSelect/Select.vue @@ -1,7 +1,7 @@ + + diff --git a/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOptions.ts b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOptions.ts new file mode 100644 index 00000000000..83009386a1e --- /dev/null +++ b/packages/frontend/editor-ui/src/features/credentials/composables/useCredentialOptions.ts @@ -0,0 +1,112 @@ +import { useRootStore } from '@n8n/stores/useRootStore'; +import { getErrorMessage } from '@n8n/utils/errors/get-error-message'; +import isEqual from 'lodash/isEqual'; +import type { + ICredentialDataDecryptedObject, + INodeListSearchItems, + INodeProperties, +} from 'n8n-workflow'; +import { computed, onScopeDispose, ref, watch } from 'vue'; + +import { getCredentialOptions, type CredentialOptionsDestination } from '../credentials.api'; + +export interface CredentialOptionsContext { + credentialType?: string; + credentialId?: string; + n8nProjectId?: string; + isInstanceCredential?: boolean; + credentialData: ICredentialDataDecryptedObject; + credentialProperties: INodeProperties[]; +} + +export function useCredentialOptions( + context: CredentialOptionsContext & { parameter: INodeProperties }, +) { + const rootStore = useRootStore(); + const options = ref([]); + const loading = ref(false); + const errorMessage = ref(''); + const filter = ref(''); + const paginationToken = ref(); + const hasMore = computed(() => Boolean(paginationToken.value)); + let revision = 0; + const destination = computed(() => { + if (context.credentialId) return { kind: 'stored', credentialId: context.credentialId }; + if (context.isInstanceCredential) return { kind: 'instance' }; + if (context.n8nProjectId) return { kind: 'project', projectId: context.n8nProjectId }; + return undefined; + }); + const dependencies = computed(() => context.parameter.typeOptions?.loadOptionsDependsOn ?? []); + const canLoad = computed(() => { + if (!context.credentialType || !destination.value) return false; + return dependencies.value.every((name) => { + // Instance overwrites can supply fields that the form does not show. + if (!context.credentialProperties.some((property) => property.name === name)) return true; + const value = context.credentialData[name]; + return typeof value === 'string' ? value.trim() !== '' : value !== undefined; + }); + }); + + async function loadOptions(pageToken?: string) { + const target = destination.value; + if (!context.credentialType || !target || !canLoad.value) return; + const requestRevision = revision; + const request = { + type: context.credentialType, + data: { ...context.credentialData }, + propertyName: context.parameter.name, + filter: filter.value || undefined, + paginationToken: pageToken, + }; + loading.value = true; + errorMessage.value = ''; + try { + const response = await getCredentialOptions(rootStore.restApiContext, target, request); + if (requestRevision !== revision) return; + options.value.push(...response.results); + paginationToken.value = response.paginationToken; + } catch (error) { + if (requestRevision === revision) errorMessage.value = getErrorMessage(error); + } finally { + if (requestRevision === revision) loading.value = false; + } + } + + function reloadOptions() { + revision++; + options.value = []; + paginationToken.value = undefined; + errorMessage.value = ''; + loading.value = false; + void loadOptions(); + } + + function search(query: string) { + const nextFilter = query.trim(); + if (nextFilter === filter.value) return; + filter.value = nextFilter; + reloadOptions(); + } + + function loadMore() { + if (loading.value || !paginationToken.value) return; + void loadOptions(paginationToken.value); + } + + watch( + () => [ + context.credentialType, + destination.value, + context.parameter.name, + ...dependencies.value.map((name) => context.credentialData[name]), + ], + (current, previous) => { + if (isEqual(current, previous)) return; + reloadOptions(); + }, + { flush: 'sync', immediate: true }, + ); + onScopeDispose(() => revision++); + + return { options, loading, errorMessage, filter, hasMore, search, loadMore }; +} diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.api.test.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.api.test.ts index 24160b8f377..d0ab150b7c8 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.api.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.api.test.ts @@ -1,10 +1,14 @@ +import { CredentialOptionsRequestDto } from '@n8n/api-types'; import { makeRestApiRequest } from '@n8n/rest-api-client'; import type { IRestApiContext } from '@n8n/rest-api-client'; +import type { CredentialInformation, ICredentialDataDecryptedObject } from 'n8n-workflow'; import { oAuth1CredentialAuthorize, oAuth2CredentialAuthorize, searchCredentials, + getCredentialOptions, + type CredentialOptionsDestination, } from './credentials.api'; import type { ICredentialsResponse } from './credentials.types'; @@ -16,6 +20,49 @@ const makeRestApiRequestMock = vi.mocked(makeRestApiRequest); const context: IRestApiContext = { baseUrl: '/rest', pushRef: 'push-ref' }; +describe('credentials.api resource locator', () => { + it.each<{ + destination: CredentialOptionsDestination; + path: string; + tokenData?: ICredentialDataDecryptedObject; + expectedTokenData?: ICredentialDataDecryptedObject; + }>([ + { + destination: { kind: 'project', projectId: 'project-id' }, + path: '/credentials/options/projects/project-id', + tokenData: { oauthTokenData: null as unknown as CredentialInformation }, + }, + { destination: { kind: 'instance' }, path: '/credentials/options/instance' }, + { + destination: { kind: 'stored', credentialId: 'credential-id' }, + path: '/credentials/credential-id/options', + tokenData: { oauthTokenData: { access_token: 'access-token' } }, + expectedTokenData: { oauthTokenData: { access_token: 'access-token' } }, + }, + ])( + 'sends a valid draft in a POST body to $path', + async ({ destination, path, tokenData, expectedTokenData }) => { + const credentialData = { email: 'service@example.com', privateKey: 'draft-key' }; + const data = { + type: 'googleVertexAiApi', + propertyName: 'project', + data: { ...credentialData, ...tokenData }, + paginationToken: 'next-page', + }; + const originalData = structuredClone(data); + await getCredentialOptions(context, destination, data); + expect(makeRestApiRequestMock).toHaveBeenLastCalledWith(context, 'POST', path, { + ...data, + data: { ...credentialData, ...expectedTokenData }, + }); + expect( + CredentialOptionsRequestDto.safeParse(makeRestApiRequestMock.mock.lastCall?.[3]).success, + ).toBe(true); + expect(data).toEqual(originalData); + }, + ); +}); + // A credential as returned by the list/edit endpoints, carrying the large fields // (homeProject, scopes, sharedWithProjects) that previously bloated the auth GET URL. const credential = { diff --git a/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts b/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts index 181925a7a97..de68656e1de 100644 --- a/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts +++ b/packages/frontend/editor-ui/src/features/credentials/credentials.api.ts @@ -1,4 +1,8 @@ -import type { CreateCredentialPublicDto, CredentialPublicDto } from '@n8n/api-types'; +import type { + CreateCredentialPublicDto, + CredentialPublicDto, + CredentialOptionsRequestDto, +} from '@n8n/api-types'; import { request, type PublicApiContext } from '@n8n/rest-api-client'; import type { CredentialFetchScope, @@ -14,6 +18,7 @@ import type { IDataObject, INodeCredentialTestRequest, INodeCredentialTestResult, + INodeListSearchResult, } from 'n8n-workflow'; import axios from 'axios'; import type { CreateCredentialDto } from '@n8n/api-types'; @@ -177,6 +182,29 @@ export async function testCredential( ); } +export type CredentialOptionsDestination = + | { kind: 'stored'; credentialId: string } + | { kind: 'project'; projectId: string } + | { kind: 'instance' }; + +export async function getCredentialOptions( + context: IRestApiContext, + destination: CredentialOptionsDestination, + data: CredentialOptionsRequestDto, +): Promise { + let path = '/credentials/options/instance'; + if (destination.kind === 'stored') { + path = `/credentials/${destination.credentialId}/options`; + } else if (destination.kind === 'project') { + path = `/credentials/options/projects/${destination.projectId}`; + } + const requestData = { ...data, data: { ...data.data } }; + if (requestData.data.oauthTokenData === null) { + delete requestData.data.oauthTokenData; + } + return await makeRestApiRequest(context, 'POST', path, requestData); +} + /** * Auth-probe a stored credential against the test URL persisted in the * credential itself (Templated Custom Auth) — for types `/credentials/test` diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 332a05d85d2..2a8b0ab2af8 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -438,6 +438,15 @@ export interface ICredentialType { extends?: string[]; properties: INodeProperties[]; documentationUrl?: string; + methods?: { + loadOptions?: { + [method: string]: ( + credentials: ICredentialDataDecryptedObject, + filter?: string, + paginationToken?: string, + ) => Promise; + }; + }; __overwrittenProperties?: string[]; __skipManagedCreation?: boolean; __showManagedOAuthScopes?: boolean;