feat(editor): Add project selection to Vertex AI credentials (#40667)

This commit is contained in:
Robin Braumann 2026-10-09 14:23:57 +00:00 committed by GitHub
parent bbd35e281b
commit cdc23c666f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1508 additions and 87 deletions

View File

@ -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<ICredentialDataDecryptedObject> = 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(),
}) {}

View File

@ -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,

View File

@ -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 } };
}

View File

@ -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,
);
},
},
};

View File

@ -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,
);
},
},
};

View File

@ -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<INodeCredentialTestResult> {
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<INodeListSearchResult> {
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();
}
}

View File

@ -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<typeof import('n8n-nodes-base/google-service-account')>()),
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<ISupplyDataFunctions>();
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<ICredentialTestFunctions>({
helpers: mock<ICredentialTestFunctions['helpers']>({ request }),
});
const credential = mock<ICredentialsDecrypted>({
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'));

View File

@ -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);
});
});

View File

@ -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<CredentialTypes>();
const helper = mock<CredentialsHelper>();
const credentials = mock<CredentialsService>();
const finder = mock<CredentialsFinderService>();
const externalSecretsConfig = mock<ExternalSecretsConfig>();
const secretProviderAccess = mock<SecretsProviderAccessCheckService>();
const service = new CredentialOptionsService(
credentialTypes,
helper,
credentials,
finder,
externalSecretsConfig,
secretProviderAccess,
);
const user = mock<User>({ id: 'user-id', role: GLOBAL_OWNER_ROLE });
const stored = mock<CredentialsEntity>({
id: 'saved-id',
name: 'Vertex',
type: 'googleVertexAiApi',
isManaged: false,
});
const loader = vi.fn();
const homeProject = mock<ProjectSharingData>({ 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<IWorkflowExecuteAdditionalData>());
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<User>({
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.',
);
});
});

View File

@ -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<CredentialsEntity>({
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(

View File

@ -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);
}
}

View File

@ -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.');
}
}
}

View File

@ -1047,7 +1047,7 @@ export class CredentialsService {
return await this.decrypt(credential, true);
}
private async enforceCredentialUse(
async enforceCredentialUse(
credential: Pick<ICredentialsDecrypted, 'id' | 'type'>,
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<ICredentialsDecrypted> {
// 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 };
}
}

View File

@ -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';

View File

@ -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: '<N8nSelect id="project" filterable :teleported="false" v-bind="controlAttrs" />',
});
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: {

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ElSelect } from 'element-plus';
import type { ComponentPublicInstance, PropType, Ref } from 'vue';
import { computed, ref, useAttrs } from 'vue';
import { computed, onMounted, onUpdated, ref, useAttrs } from 'vue';
import type { InnerSelectRef, N8nSelectExposed } from './Select.types';
import type { SelectSize } from '../../types';
@ -84,6 +84,26 @@ const setInnerSelect = (el: Element | ComponentPublicInstance | null) => {
innerSelect.value = (el as InnerSelectRef | null) ?? null;
};
const syncControlAriaAttributes = () => {
const selectWrapper = innerSelect.value?.$refs.selectWrapper;
if (!(selectWrapper instanceof HTMLElement)) return;
const input = selectWrapper.querySelector('input[role="combobox"]');
if (!input) return;
for (const attribute of ['aria-invalid', 'aria-describedby', 'aria-busy']) {
const value = attrs[attribute];
if (typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number') {
input.setAttribute(attribute, String(value));
} else {
input.removeAttribute(attribute);
}
}
};
onMounted(syncControlAriaAttributes);
onUpdated(syncControlAriaAttributes);
const listeners = computed(() => {
return Object.entries(attrs).reduce<Record<string, unknown>>((acc, [key, value]) => {
if (isEventBindingElementAttribute(value, key)) {

View File

@ -1048,6 +1048,9 @@
"communityNodeFooter.legacy": "Legacy",
"communityNodeFooter.manage": "Manage",
"communityNodeFooter.reportIssue": "Report issue",
"credentialEdit.options.empty": "No options found.",
"credentialEdit.options.error": "{message} Select Custom to enter an ID.",
"credentialEdit.options.loadMore": "Load more",
"credentialEdit.credentialConfig.accountConnected": "Account connected",
"credentialEdit.credentialConfig.accountNotConnected": "Connect your account to use this credential",
"credentialEdit.credentialConfig.accountNotConnected.endUser": "Connect your {service} account",

View File

@ -67,6 +67,8 @@ type Props = {
credentialProperties: INodeProperties[];
credentialData: ICredentialDataDecryptedObject;
credentialId?: string;
n8nProjectId?: string;
isInstanceCredential?: boolean;
credentialPermissions: PermissionsRecord['credential'];
parentTypes?: string[];
showValidationWarning?: boolean;
@ -783,6 +785,10 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
/>
<CredentialInputs
v-else-if="credentialType && canWrite"
:credential-type="credentialType.name"
:credential-id="credentialId"
:n8n-project-id="n8nProjectId"
:is-instance-credential="isInstanceCredential"
:credential-data="credentialData"
:credential-properties="credentialProperties"
:documentation-url="documentationUrl"

View File

@ -27,6 +27,12 @@ import { useAiGatewayStore } from '@/app/stores/aiGateway.store';
import { reactive } from 'vue';
import { CREDENTIAL_DESCRIPTION_MAX_LENGTH } from '@n8n/api-types';
import { TEMPLATED_CUSTOM_AUTH_CREDENTIAL_TYPE } from '../../templatedAuth.utils';
import { getCredentialOptions } from '../../credentials.api';
vi.mock('../../credentials.api', async (importOriginal) => ({
...(await importOriginal<object>()),
getCredentialOptions: vi.fn(),
}));
const { isAgentUi, getAgentCredentialHelp } = vi.hoisted(() => ({
isAgentUi: { value: false },
@ -1794,6 +1800,109 @@ describe('CredentialEdit', () => {
);
});
test.each(['custom', 'listed'])(
'saves a %s project without a credential test',
async (mode) => {
const credentialType: ICredentialType = {
name: 'googleVertexAiApi',
displayName: 'Google Vertex AI',
properties: [
{
name: 'email',
displayName: 'Service Account Email',
type: 'string',
default: '',
required: true,
},
{
name: 'privateKey',
displayName: 'Private Key',
type: 'string',
default: '',
required: true,
typeOptions: { password: true },
},
{
name: 'project',
displayName: 'Project',
type: 'options',
options: [{ name: 'Custom', value: '__custom__' }],
default: '__custom__',
typeOptions: {
loadOptionsMethod: 'projects',
loadOptionsDependsOn: ['email', 'privateKey'],
},
},
{
name: 'projectId',
displayName: 'Project ID',
type: 'string',
required: true,
default: '',
displayOptions: { show: { project: ['__custom__'] } },
},
],
};
const { credentialsStore, pinia, uiStore } = setupNewCredential(credentialType, {
initialData: { email: 'service@example.com', privateKey: 'draft-key' },
closeOnSave: true,
});
if (mode === 'custom') {
vi.mocked(getCredentialOptions).mockRejectedValue(new Error('Permission denied'));
} else {
vi.mocked(getCredentialOptions).mockResolvedValue({
results: [{ name: 'Target project', value: 'target-project' }],
});
}
const view = renderComponent({
props: {
activeId: credentialType.name,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'new',
},
pinia,
});
const saveButton = await view.findByRole('button', { name: 'Save' });
const projectSelect = await view.findByLabelText('Project');
const projectIdForm = view.getByText('Project ID').closest('form')!;
const projectIdInput = within(projectIdForm).getByRole('textbox');
expect(projectSelect).toHaveValue('Custom');
expect(projectIdInput).toBeVisible();
expect(saveButton).toBeDisabled();
expect(credentialsStore.createNewCredential).not.toHaveBeenCalled();
if (mode === 'custom') {
await userEvent.click(projectIdInput);
await userEvent.paste('target-project');
} else {
await waitFor(() => expect(projectSelect).toBeEnabled());
await userEvent.click(projectSelect);
await userEvent.click(await view.findByText('Target project'));
expect(view.queryByText('Project ID')).toBeNull();
expect(projectIdInput).not.toBeInTheDocument();
}
await waitFor(() => expect(saveButton).toBeEnabled());
await userEvent.click(saveButton);
await waitFor(() =>
expect(credentialsStore.createNewCredential).toHaveBeenCalledWith(
expect.objectContaining({
type: 'googleVertexAiApi',
data: {
email: 'service@example.com',
privateKey: 'draft-key',
...(mode === 'custom'
? { projectId: 'target-project' }
: { project: 'target-project' }),
},
}),
'personal-project',
undefined,
),
);
expect(credentialsStore.testCredential).not.toHaveBeenCalled();
expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY);
},
);
test('closes the modal after saving credentials that cannot be tested when closeOnSave is enabled', async () => {
const credentialType = {
name: 'testApi',

View File

@ -1737,6 +1737,8 @@ const { width } = useElementSize(credNameRef);
:credential-properties="credentialProperties"
:credential-data="credentialData"
:credential-id="credentialId"
:n8n-project-id="homeProject?.id"
:is-instance-credential="isInstanceCredential"
:is-managed="isEditingManagedCredential"
:show-validation-warning="showValidationWarning"
:auth-error="authError"

View File

@ -1,9 +1,21 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { fireEvent } from '@testing-library/vue';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import {
CREDENTIAL_BLANKING_VALUE,
type INodeProperties,
type INodeListSearchResult,
} from 'n8n-workflow';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import CredentialInputs from './CredentialInputs.vue';
import { getCredentialOptions } from '../../credentials.api';
vi.mock('../../credentials.api', async (importOriginal) => ({
...(await importOriginal<object>()),
getCredentialOptions: vi.fn(),
}));
const renderComponent = createComponentRenderer(CredentialInputs);
@ -90,3 +102,266 @@ describe('compact CredentialInputs', () => {
expect(secretLabel).toBeVisible();
});
});
describe('credential project options', () => {
const properties: INodeProperties[] = [
{
name: 'email',
displayName: 'Service Account Email',
type: 'string',
default: '',
required: true,
},
{
name: 'privateKey',
displayName: 'Private Key',
type: 'string',
default: '',
required: true,
typeOptions: { password: true },
},
{
name: 'project',
displayName: 'Project',
type: 'options',
options: [{ name: 'Custom', value: '__custom__' }],
default: '__custom__',
required: true,
typeOptions: { loadOptionsMethod: 'projects', loadOptionsDependsOn: ['email', 'privateKey'] },
},
];
const data = {
email: 'service@example.com',
privateKey: 'draft-key',
region: 'global',
project: '__custom__',
projectId: 'saved-project',
};
const renderOptions = createComponentRenderer(CredentialInputs, {
props: {
credentialProperties: properties,
credentialData: data,
documentationUrl: '',
credentialType: 'googleVertexAiApi',
n8nProjectId: 'n8n-project',
},
});
const lookup = vi.mocked(getCredentialOptions);
beforeEach(() => {
setActivePinia(createTestingPinia());
lookup.mockReset();
lookup.mockResolvedValue({
results: [{ name: 'Target project (target-project)', value: 'target-project' }],
});
});
it.each([undefined, 'saved-credential'])(
'loads pages on demand and keeps manual entry available for credential %s',
async (credentialId) => {
let resolveNextPage!: (result: INodeListSearchResult) => void;
lookup.mockResolvedValueOnce({
results: [{ name: 'First project (first-project)', value: 'first-project' }],
paginationToken: 'next-page',
});
lookup.mockReturnValueOnce(
new Promise((resolve) => {
resolveNextPage = resolve;
}),
);
const credentialData = {
...data,
project: 'first-project',
privateKey: credentialId ? CREDENTIAL_BLANKING_VALUE : 'draft-key',
};
const view = renderOptions({ props: { credentialId, credentialData } });
const input = await view.findByLabelText('Project');
await waitFor(() => expect(input).toHaveValue('First project (first-project)'));
expect(view.queryByRole('status')).toBeNull();
expect(lookup).toHaveBeenCalledTimes(1);
await userEvent.click(input);
expect(view.getByRole('option', { name: 'First project (first-project)' })).toBeVisible();
await userEvent.click(view.getByRole('button', { name: 'Load more' }));
expect(lookup).toHaveBeenLastCalledWith(
expect.anything(),
credentialId
? { kind: 'stored', credentialId }
: { kind: 'project', projectId: 'n8n-project' },
expect.objectContaining({ paginationToken: 'next-page' }),
);
expect(view.getByRole('status')).toBeVisible();
await userEvent.click(view.getByRole('option', { name: 'Custom' }));
expect(view.emitted('update')).toContainEqual([{ name: 'project', value: '__custom__' }]);
await view.rerender({
credentialData: { ...credentialData, project: '__custom__' },
credentialProperties: [
...properties,
{
name: 'projectId',
displayName: 'Project ID',
type: 'string',
default: '',
required: true,
placeholder: 'my-project-id',
displayOptions: { show: { project: ['__custom__'] } },
},
],
});
const manualProjectInput = view.getByPlaceholderText('my-project-id');
expect(manualProjectInput).toHaveValue('saved-project');
await fireEvent.update(manualProjectInput, 'manual-project');
expect(view.emitted('update')).toContainEqual([
{ name: 'projectId', value: 'manual-project' },
]);
expect(view.getByRole('status')).toBeVisible();
resolveNextPage({
results: [{ name: 'Target project (target-project)', value: 'target-project' }],
});
await waitFor(() => expect(view.queryByRole('status')).toBeNull());
await userEvent.click(input);
expect(await view.findByText('First project (first-project)')).toBeVisible();
const updatesBeforeFiltering = view.emitted('update')?.slice();
await userEvent.type(input, 'Target', { skipClick: true });
await waitFor(() =>
expect(lookup).toHaveBeenLastCalledWith(
expect.anything(),
credentialId
? { kind: 'stored', credentialId }
: { kind: 'project', projectId: 'n8n-project' },
{
type: 'googleVertexAiApi',
data: { ...credentialData, project: '__custom__' },
propertyName: 'project',
filter: 'Target',
paginationToken: undefined,
},
),
);
expect(view.emitted('update')).toEqual(updatesBeforeFiltering);
expect(view.queryByRole('option', { name: 'First project (first-project)' })).toBeNull();
await userEvent.keyboard('{Enter}');
expect(view.emitted('update')).toContainEqual([{ name: 'project', value: 'target-project' }]);
},
);
it('loads projects when both service account fields are filled without opening the list', async () => {
const view = renderOptions({
props: {
showValidationWarnings: true,
credentialData: { ...data, email: '', privateKey: '' },
},
});
const input = await view.findByLabelText('Project');
expect(input).toHaveValue('Custom');
expect(input).toBeEnabled();
expect(within(input.closest('form')!).queryByRole('alert')).toBeNull();
expect(within(input.closest('form')!).queryByRole('status')).toBeNull();
await view.rerender({ credentialData: { ...data, privateKey: '' } });
expect(lookup).not.toHaveBeenCalled();
await view.rerender({ credentialData: data });
await waitFor(() => expect(lookup).toHaveBeenCalled());
await waitFor(() => expect(input).toBeEnabled());
expect(input).toHaveValue('Custom');
await userEvent.click(input);
expect(await view.findByText('Target project (target-project)')).toBeVisible();
});
it('discards old results and keeps the project and cached options when Region changes', async () => {
let resolveOldRequest!: (result: INodeListSearchResult) => void;
lookup.mockReturnValueOnce(
new Promise((resolve) => {
resolveOldRequest = resolve;
}),
);
const view = renderOptions();
const input = await view.findByLabelText('Project');
await waitFor(() => expect(lookup).toHaveBeenCalledTimes(1));
await view.rerender({ credentialData: { ...data, privateKey: 'updated-key' } });
await waitFor(() => expect(input).toBeEnabled());
await userEvent.click(input);
expect(await view.findByText('Target project (target-project)')).toBeVisible();
resolveOldRequest({ results: [{ name: 'Outdated project', value: 'old-project' }] });
await flushPromises();
expect(view.queryByText('Outdated project')).toBeNull();
await view.rerender({ credentialData: { ...data, privateKey: 'updated-key', region: 'eu' } });
await userEvent.keyboard('{Escape}');
expect(input).toHaveValue('Custom');
await userEvent.click(input);
expect(await view.findByText('Target project (target-project)')).toBeVisible();
expect(lookup).toHaveBeenCalledTimes(2);
expect(view.emitted('update')).toBeUndefined();
});
it.each(['success', 'failure'])(
'ignores an old page %s after the search changes',
async (result) => {
let resolveOldPage!: (value: INodeListSearchResult) => void;
let rejectOldPage!: (error: Error) => void;
lookup.mockResolvedValueOnce({
results: [{ name: 'First project', value: 'first-project' }],
paginationToken: 'next-page',
});
lookup.mockReturnValueOnce(
new Promise((resolve, reject) => {
resolveOldPage = resolve;
rejectOldPage = reject;
}),
);
const view = renderOptions();
const input = await view.findByLabelText('Project');
await userEvent.click(input);
await userEvent.click(await view.findByRole('button', { name: 'Load more' }));
await userEvent.type(input, 'Target', { skipClick: true });
expect(await view.findByText('Target project (target-project)')).toBeVisible();
if (result === 'failure') {
rejectOldPage(new Error('Outdated error'));
} else {
resolveOldPage({
results: [{ name: 'Outdated project', value: 'old-project' }],
paginationToken: 'outdated-page',
});
}
await flushPromises();
expect(view.queryByText('Outdated project')).toBeNull();
expect(view.queryByRole('alert')).toBeNull();
expect(view.queryByRole('button', { name: 'Load more' })).toBeNull();
expect(view.getByRole('option', { name: 'Target project (target-project)' })).toBeVisible();
},
);
it('shows lookup errors with the Custom fallback and clears them after credentials change', async () => {
const message = 'Could not load the list. Try again or enter an ID.';
lookup.mockRejectedValueOnce(new Error(message));
const view = renderOptions();
const input = await view.findByLabelText('Project');
const alert = await view.findByRole('alert');
expect(alert).toHaveTextContent(message);
expect(alert).toHaveTextContent('Select Custom to enter an ID.');
expect(input).toHaveAttribute('aria-describedby', alert.id);
expect(input).toBeEnabled();
expect(input).toHaveValue('Custom');
expect(view.emitted('update')).toBeUndefined();
await userEvent.click(input);
await userEvent.click(view.getByRole('option', { name: 'Custom' }));
await view.rerender({ credentialData: { ...data, privateKey: 'updated-key' } });
await userEvent.click(input);
expect(await view.findByText('Target project (target-project)')).toBeVisible();
expect(view.queryByRole('alert')).toBeNull();
expect(input).not.toHaveAttribute('aria-describedby');
});
it('keeps Custom without an error when no projects are found', async () => {
lookup.mockResolvedValue({ results: [] });
const view = renderOptions();
const input = await view.findByLabelText('Project');
await waitFor(() => expect(input).toBeEnabled());
expect(input).toHaveValue('Custom');
expect(view.queryByRole('status')).toBeNull();
expect(view.queryByRole('alert')).toBeNull();
expect(view.emitted('update')).toBeUndefined();
await userEvent.click(input);
expect(view.getAllByRole('option')).toHaveLength(1);
await userEvent.click(await view.findByText('Custom'));
expect(input).toHaveValue('Custom');
});
});

View File

@ -9,6 +9,7 @@ import CopyInput from '@/app/components/CopyInput.vue';
import ParameterInputExpanded from '@/features/ndv/parameters/components/ParameterInputExpanded.vue';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { computed, useId } from 'vue';
import CredentialOptionsInput from './CredentialOptionsInput.vue';
import { N8nInput, N8nInputLabel, N8nNotice, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@ -19,6 +20,9 @@ type Props = {
showValidationWarnings?: boolean;
compact?: boolean;
credentialType?: string;
credentialId?: string;
n8nProjectId?: string;
isInstanceCredential?: boolean;
};
const props = defineProps<Props>();
@ -66,6 +70,13 @@ function valueChanged(parameterData: IUpdateInformation) {
>
<!-- Why form? to break up inputs, to prevent Chrome autofill -->
<N8nNotice v-if="parameter.type === 'notice'" :content="parameter.displayName" />
<CredentialOptionsInput
v-else-if="parameter.type === 'options' && parameter.typeOptions?.loadOptionsMethod"
v-bind="props"
:parameter="parameter"
:model-value="String(credentialDataValues[parameter.name] ?? '')"
@update:model-value="valueChanged({ name: parameter.name, value: $event })"
/>
<CopyInput
v-else-if="parameter.type === 'string' && parameter.typeOptions?.copyButton"
:label="parameter.displayName"

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import {
N8nButton,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nSpinner,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { INodeProperties } from 'n8n-workflow';
import { computed, useId } from 'vue';
import {
useCredentialOptions,
type CredentialOptionsContext,
} from '../../composables/useCredentialOptions';
const props = defineProps<
CredentialOptionsContext & {
parameter: INodeProperties;
modelValue: string;
showValidationWarnings?: boolean;
compact?: boolean;
}
>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const i18n = useI18n();
const inputId = useId();
const {
options: loadedOptions,
loading,
errorMessage,
filter,
hasMore,
search,
loadMore,
} = useCredentialOptions(props);
const options = computed(() => {
const staticOptions = props.parameter.options?.filter((option) => 'value' in option) ?? [];
const query = filter.value.toLowerCase();
return [
...staticOptions.filter((option) => option.name.toLowerCase().includes(query)),
...loadedOptions.value,
];
});
const hasRequiredError = computed(
() => props.parameter.required && props.showValidationWarnings && !props.modelValue,
);
const fieldError = computed(() => {
if (hasRequiredError.value) {
return i18n.baseText('parameterInputExpanded.thisFieldIsRequired');
}
if (errorMessage.value) {
return i18n.baseText('credentialEdit.options.error', {
interpolate: { message: errorMessage.value },
});
}
return '';
});
</script>
<template>
<N8nInputLabel
:input-name="inputId"
:label="i18n.credText(credentialType ?? '').inputLabelDisplayName(parameter)"
:tooltip-text="i18n.credText(credentialType ?? '').inputLabelDescription(parameter)"
:bold="!compact"
:size="compact ? 'small' : 'medium'"
show-tooltip
>
<N8nSelect
:id="inputId"
:model-value="modelValue"
:size="compact ? 'small' : 'large'"
:placeholder="parameter.placeholder"
:teleported="false"
:no-data-text="i18n.baseText('credentialEdit.options.empty')"
:aria-invalid="Boolean(hasRequiredError)"
:aria-busy="loading || undefined"
:aria-describedby="fieldError ? `${inputId}-error` : undefined"
filterable
remote
remote-show-suffix
:remote-method="search"
default-first-option
@update:model-value="emit('update:modelValue', $event)"
>
<template v-if="loading" #prefix>
<N8nSpinner role="status" :aria-label="i18n.baseText('parameterInput.loadingOptions')" />
</template>
<N8nOption
v-for="option in options"
:key="String(option.value)"
:value="String(option.value)"
:label="option.name"
/>
<template v-if="hasMore" #footer>
<N8nButton
variant="ghost"
size="small"
:label="i18n.baseText('credentialEdit.options.loadMore')"
:disabled="loading"
@mousedown.prevent
@click.stop="loadMore"
/>
</template>
</N8nSelect>
<N8nText
v-if="fieldError"
:id="`${inputId}-error`"
class="mt-2xs"
size="small"
color="danger"
role="alert"
>
{{ fieldError }}
</N8nText>
</N8nInputLabel>
</template>

View File

@ -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<INodeListSearchItems[]>([]);
const loading = ref(false);
const errorMessage = ref('');
const filter = ref('');
const paginationToken = ref<string>();
const hasMore = computed(() => Boolean(paginationToken.value));
let revision = 0;
const destination = computed<CredentialOptionsDestination | undefined>(() => {
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 };
}

View File

@ -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 = {

View File

@ -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<INodeListSearchResult> {
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`

View File

@ -438,6 +438,15 @@ export interface ICredentialType {
extends?: string[];
properties: INodeProperties[];
documentationUrl?: string;
methods?: {
loadOptions?: {
[method: string]: (
credentials: ICredentialDataDecryptedObject,
filter?: string,
paginationToken?: string,
) => Promise<INodeListSearchResult>;
};
};
__overwrittenProperties?: string[];
__skipManagedCreation?: boolean;
__showManagedOAuthScopes?: boolean;