mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(core): Scope AI Assistant credential setup cards to the thread's project (#36425)
This commit is contained in:
parent
df924f8eb3
commit
b50ea9cd42
|
|
@ -101,6 +101,9 @@ export const credentialSuspendPayloadSchema = z.object({
|
|||
severity: z.literal('info'),
|
||||
credentialRequests: z.array(credentialRequestSchema).min(1),
|
||||
credentialFlow: z.object({ stage: z.literal('generic') }),
|
||||
// Scopes the FE credential picker to the builder's project — without it the
|
||||
// picker falls back to the user's personal project.
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
export type CredentialSuspendPayload = z.infer<typeof credentialSuspendPayloadSchema>;
|
||||
|
||||
|
|
|
|||
|
|
@ -1616,6 +1616,27 @@ describe('workflows tool', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should scope the setup suspend to the thread-bound project even when the model omits projectId', async () => {
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([
|
||||
{
|
||||
node: { name: 'Slack', type: 'n8n-nodes-base.slack' },
|
||||
credentialType: 'slackApi',
|
||||
needsAction: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const context = createMockContext({ projectId: 'project-team-1' });
|
||||
const suspend = vi.fn();
|
||||
|
||||
const tool = createWorkflowsTool(context, 'full');
|
||||
await executeTool(tool, { action: 'setup', workflowId: 'wf1' }, {
|
||||
suspend,
|
||||
resumeData: undefined,
|
||||
} as never);
|
||||
|
||||
expect(suspend.mock.calls[0][0]).toMatchObject({ projectId: 'project-team-1' });
|
||||
});
|
||||
|
||||
it('should scope setup requests to nodes changed by the latest build', async () => {
|
||||
(analyzeWorkflow as Mock).mockResolvedValue([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -669,13 +669,18 @@ async function handleSetupTestTrigger(
|
|||
// as already-resolved from the previous suspend cycle
|
||||
state.currentRequestId = nanoid();
|
||||
|
||||
// The thread-bound project is authoritative for credential scoping; without
|
||||
// it the frontend falls back to the user's personal project and offers
|
||||
// credentials from outside the conversation's project.
|
||||
const projectId = context.projectId ?? input.projectId;
|
||||
|
||||
return await ctx.suspend({
|
||||
requestId: state.currentRequestId,
|
||||
message: 'Configure credentials for your workflow',
|
||||
severity: 'info' as const,
|
||||
setupRequests: refreshedRequests,
|
||||
workflowId: input.workflowId,
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -898,13 +903,18 @@ async function handleSetup(
|
|||
|
||||
state.currentRequestId = nanoid();
|
||||
|
||||
// The thread-bound project is authoritative for credential scoping; without
|
||||
// it the frontend falls back to the user's personal project and offers
|
||||
// credentials from outside the conversation's project.
|
||||
const projectId = context.projectId ?? input.projectId;
|
||||
|
||||
return await ctx.suspend({
|
||||
requestId: state.currentRequestId,
|
||||
message: 'Configure credentials for your workflow',
|
||||
severity: 'info' as const,
|
||||
setupRequests,
|
||||
workflowId: input.workflowId,
|
||||
...(input.projectId ? { projectId: input.projectId } : {}),
|
||||
...(projectId ? { projectId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -853,6 +853,7 @@ export class AgentsBuilderToolsService {
|
|||
}),
|
||||
buildAskCredentialTool({
|
||||
credentialProvider,
|
||||
projectId,
|
||||
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
|
||||
listIntegrationCredentialIds: async () => {
|
||||
const agent = await this.agentsService.findById(agentId, projectId);
|
||||
|
|
@ -864,6 +865,7 @@ export class AgentsBuilderToolsService {
|
|||
}),
|
||||
buildAskEmbeddingCredentialTool({
|
||||
credentialProvider,
|
||||
projectId,
|
||||
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
|
||||
isAssistantProxyEnabled: () => this.aiService.isProxyEnabled(),
|
||||
track,
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@ function makeProvider(creds: CredentialListItem[]): CredentialProvider {
|
|||
|
||||
let track: Mock;
|
||||
|
||||
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track'>) {
|
||||
const merged: AskCredentialToolDeps = { ...deps, track };
|
||||
function askCredentialTool(deps: Omit<AskCredentialToolDeps, 'track' | 'projectId'>) {
|
||||
const merged: AskCredentialToolDeps = { projectId: 'project-1', ...deps, track };
|
||||
return buildAskCredentialTool(merged);
|
||||
}
|
||||
|
||||
function askEmbeddingCredentialTool(deps: Omit<AskEmbeddingCredentialToolDeps, 'track'>) {
|
||||
const merged: AskEmbeddingCredentialToolDeps = { ...deps, track };
|
||||
function askEmbeddingCredentialTool(
|
||||
deps: Omit<AskEmbeddingCredentialToolDeps, 'track' | 'projectId'>,
|
||||
) {
|
||||
const merged: AskEmbeddingCredentialToolDeps = { projectId: 'project-1', ...deps, track };
|
||||
return buildAskEmbeddingCredentialTool(merged);
|
||||
}
|
||||
|
||||
|
|
@ -189,6 +191,7 @@ describe('ask_credential tool', () => {
|
|||
},
|
||||
],
|
||||
credentialFlow: { stage: 'generic' },
|
||||
projectId: 'project-1',
|
||||
}),
|
||||
);
|
||||
expect(track).toHaveBeenCalledWith(TELEMETRY_EVENT.AGENTS.BUILDER_REQUESTED_CREDENTIAL, {
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ describe('finish_setup tool', () => {
|
|||
],
|
||||
},
|
||||
]);
|
||||
expect(payload.projectId).toBe('project-1');
|
||||
expect(
|
||||
(payload.finishSetupChain as { collected: { credentials: unknown } }).collected.credentials,
|
||||
).toEqual({
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import type { BuilderTrackFn } from '../builder-config-telemetry';
|
|||
|
||||
export interface AskCredentialToolDeps {
|
||||
credentialProvider: CredentialProvider;
|
||||
/** Project the agent lives in — scopes the FE credential picker. */
|
||||
projectId: string;
|
||||
isCredentialTypeKnown?: (credentialType: string) => boolean;
|
||||
/**
|
||||
* Credential ids of the agent's configured chat channel integrations. When
|
||||
|
|
@ -159,6 +161,7 @@ async function resolveCredentialSelection(
|
|||
},
|
||||
],
|
||||
credentialFlow: { stage: 'generic' as const },
|
||||
projectId: deps.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,7 @@ async function suspendForPhase(params: {
|
|||
severity: 'info' as const,
|
||||
credentialRequests,
|
||||
credentialFlow: { stage: 'generic' as const },
|
||||
projectId: deps.projectId,
|
||||
finishSetupChain,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
|
||||
const credentialsStore = useCredentialsStore();
|
||||
vi.spyOn(credentialsStore, 'fetchAllCredentials').mockResolvedValue([]);
|
||||
vi.spyOn(credentialsStore, 'fetchAllCredentialsForWorkflow').mockResolvedValue([]);
|
||||
vi.spyOn(credentialsStore, 'fetchCredentialTypes').mockResolvedValue(undefined);
|
||||
// The card renders the NodeCredentials picker when the store has a usable
|
||||
// credential of the type; default to one so the picker-based tests render it.
|
||||
|
|
@ -234,6 +235,25 @@ describe('InstanceAiCredentialSetup', () => {
|
|||
expect(getAllByTestId('credential-picker')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preloads only project-scoped credentials when a projectId is provided', async () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const requests = makeCredentialRequestsWithExisting(1);
|
||||
renderComponent({
|
||||
props: {
|
||||
requestId: 'req-1',
|
||||
credentialRequests: requests,
|
||||
message: 'Set up credentials',
|
||||
projectId: 'project-team-1',
|
||||
},
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
expect(credentialsStore.fetchAllCredentialsForWorkflow).toHaveBeenCalledWith({
|
||||
projectId: 'project-team-1',
|
||||
});
|
||||
expect(credentialsStore.fetchAllCredentials).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the setup button (modal path) for a plain type with no usable credentials', () => {
|
||||
const credentialsStore = useCredentialsStore();
|
||||
stubUsableCredentials(credentialsStore, () => []);
|
||||
|
|
|
|||
|
|
@ -431,12 +431,14 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
|||
<!-- ============ Standalone items (no approval wrapper) ============ -->
|
||||
<template v-if="chunk.type === 'standalone'">
|
||||
<!-- Workflow setup -->
|
||||
<!-- Threads are project-bound: fall back to the thread's project so a
|
||||
payload without projectId never degrades to the personal project. -->
|
||||
<InstanceAiWorkflowSetup
|
||||
v-if="chunk.item.toolCall.confirmation.setupRequests?.length"
|
||||
:key="'setup-' + chunk.item.toolCall.confirmation.requestId"
|
||||
:request-id="chunk.item.toolCall.confirmation.requestId"
|
||||
:setup-requests="chunk.item.toolCall.confirmation.setupRequests!"
|
||||
:project-id="chunk.item.toolCall.confirmation.projectId"
|
||||
:project-id="chunk.item.toolCall.confirmation.projectId ?? thread.projectId"
|
||||
:credential-flow="chunk.item.toolCall.confirmation.credentialFlow"
|
||||
:workflow-id="chunk.item.toolCall.confirmation.workflowId"
|
||||
/>
|
||||
|
|
@ -448,7 +450,7 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
|||
:request-id="chunk.item.toolCall.confirmation.requestId"
|
||||
:credential-requests="chunk.item.toolCall.confirmation.credentialRequests!"
|
||||
:message="chunk.item.toolCall.confirmation.message"
|
||||
:project-id="chunk.item.toolCall.confirmation.projectId"
|
||||
:project-id="chunk.item.toolCall.confirmation.projectId ?? thread.projectId"
|
||||
:credential-flow="chunk.item.toolCall.confirmation.credentialFlow"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -226,10 +226,14 @@ onMounted(async () => {
|
|||
|
||||
// Ensure the credentials store is populated so NodeCredentials can show
|
||||
// existing credentials in the dropdown. The Instance AI page may not have
|
||||
// fetched them yet.
|
||||
// fetched them yet. Scope to the thread's project — an unscoped fetch fills
|
||||
// the store with credentials from other projects (e.g. personal) that the
|
||||
// picker would then offer.
|
||||
try {
|
||||
await Promise.all([
|
||||
credentialsStore.fetchAllCredentials(),
|
||||
props.projectId
|
||||
? credentialsStore.fetchAllCredentialsForWorkflow({ projectId: props.projectId })
|
||||
: credentialsStore.fetchAllCredentials(),
|
||||
credentialsStore.fetchCredentialTypes(false),
|
||||
]);
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user