mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(editor): Add project selection to Vertex AI credentials (#40667)
This commit is contained in:
parent
bbd35e281b
commit
cdc23c666f
|
|
@ -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(),
|
||||
}) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
132
packages/cli/src/credentials/credential-options.service.ts
Normal file
132
packages/cli/src/credentials/credential-options.service.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user