fix(editor): Restore agent channel credential setup (backport to release-candidate/2.32.x) (#34757)

Co-authored-by: Michael Drury <me@michaeldrury.co.uk>
This commit is contained in:
n8n-assistant[bot] 2026-07-23 06:56:53 +00:00 committed by GitHub
parent e985cbac2a
commit 53e48ee8b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 143 additions and 4 deletions

View File

@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
const {
fetchAllCredentialsForWorkflowMock,
fetchProjectMock,
projectsStoreMock,
setCredentialsMock,
} = vi.hoisted(() => ({
fetchAllCredentialsForWorkflowMock: vi.fn(),
fetchProjectMock: vi.fn(),
projectsStoreMock: {
currentProject: null as { id: string; scopes?: string[] } | null,
personalProject: null as { id: string; scopes?: string[] } | null,
myProjects: [] as Array<{ id: string; scopes?: string[] }>,
fetchProject: vi.fn(),
},
setCredentialsMock: vi.fn(),
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: {} }),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: () => ({
isModalActiveById: {},
openNewCredential: vi.fn(),
openExistingCredential: vi.fn(),
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
setCredentials: setCredentialsMock,
fetchAllCredentialsForWorkflow: fetchAllCredentialsForWorkflowMock,
getCredentialTypeByName: vi.fn(),
}),
}));
vi.mock('@/features/collaboration/projects/projects.store', () => ({
useProjectsStore: () => projectsStoreMock,
}));
function createChannelSetup() {
return useAgentChannelSetup({
projectId: () => 'artifact-project',
agentId: () => 'agent-1',
currentIntegration: null,
connectedCredentials: {},
fetchStatus: vi.fn().mockResolvedValue(undefined),
isIntegrationConnected: () => false,
});
}
describe('useAgentChannelSetup', () => {
beforeEach(() => {
vi.clearAllMocks();
projectsStoreMock.currentProject = null;
projectsStoreMock.personalProject = null;
projectsStoreMock.myProjects = [];
projectsStoreMock.fetchProject = fetchProjectMock;
fetchAllCredentialsForWorkflowMock.mockResolvedValue([]);
fetchProjectMock.mockResolvedValue({
id: 'artifact-project',
name: 'Artifact project',
icon: null,
type: 'team',
description: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
relations: [],
scopes: ['credential:create'],
rolesManaged: false,
});
});
it('uses project scopes already available in the store', async () => {
projectsStoreMock.myProjects = [{ id: 'artifact-project', scopes: ['credential:create'] }];
const { credentialPermissions, loadChannelState } = createChannelSetup();
await loadChannelState([]);
expect(credentialPermissions.value.create).toBe(true);
expect(fetchProjectMock).not.toHaveBeenCalled();
});
it('loads scopes when an artifact project is missing from the store', async () => {
// AGENT-443: artifact mode supplies its project through props rather than the route.
const { credentialPermissions, loadChannelState } = createChannelSetup();
expect(credentialPermissions.value.create).toBe(false);
await loadChannelState([]);
expect(fetchProjectMock).toHaveBeenCalledWith('artifact-project');
expect(credentialPermissions.value.create).toBe(true);
});
});

View File

@ -11,6 +11,7 @@ import {
} from '@n8n/design-system';
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
import { useI18n } from '@n8n/i18n';
import { FocusScope } from 'reka-ui';
import { computed, ref, watch } from 'vue';
import { useAgentChannelSetup } from '../composables/useAgentChannelSetup';
import { useAgentIntegrationStatus } from '../composables/useAgentIntegrationStatus';
@ -245,6 +246,15 @@ watch(
@interact-outside="(e) => e.preventDefault()"
@update:open="$emit('update:open', $event)"
>
<FocusScope
v-if="credentialModalOpen"
as-child
@mount-auto-focus.prevent
@unmount-auto-focus.prevent
>
<span hidden aria-hidden="true" />
</FocusScope>
<N8nDialogHeader :class="$style.customHeader">
<Transition name="channel-header-fade" mode="out-in">
<div v-if="currentView === 'list'" key="list" :class="$style.headerContent">
@ -277,7 +287,7 @@ watch(
</Transition>
</N8nDialogHeader>
<div :class="$style.container">
<div data-testid="agent-channel-modal" :class="$style.container">
<Transition name="channel-view-fade" mode="out-in">
<div v-if="currentView === 'list'" key="list" :class="$style.listView">
<ul :class="$style.channelList">
@ -467,6 +477,13 @@ watch(
</N8nDialog>
</template>
<style lang="scss">
body:has([data-testid='agent-channel-modal'])
.el-overlay:has([data-test-id='editCredential-modal']) {
pointer-events: auto;
}
</style>
<style module lang="scss">
@use '@n8n/design-system/css/mixins/motion';

View File

@ -7,6 +7,7 @@ import { useUIStore } from '@/app/stores/ui.store';
import { CREDENTIAL_EDIT_MODAL_KEY } from '@/features/credentials/credentials.constants';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { Project } from '@/features/collaboration/projects/projects.types';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
import { createSlackAgentApp } from './useAgentApi';
@ -42,6 +43,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const pendingNewCredentialType = ref<string | null>(null);
const channelSetupRef = ref<ChannelSetupComponent>();
const loadedIntegrations = ref<ChatIntegrationDescriptor[]>([]);
const fetchedProjectForPermissions = ref<Project | null>(null);
const projectId = computed(() => toValue(options.projectId));
const agentId = computed(() => toValue(options.agentId));
@ -49,9 +51,17 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
const connectedCredentials = computed(() => toValue(options.connectedCredentials));
const projectForPermissions = computed(() => {
if (projectsStore.currentProject?.id === projectId.value) return projectsStore.currentProject;
if (projectsStore.personalProject?.id === projectId.value) return projectsStore.personalProject;
return projectsStore.myProjects.find((project) => project.id === projectId.value) ?? null;
const storedProject =
[
projectsStore.currentProject,
projectsStore.personalProject,
...projectsStore.myProjects,
].find((project) => project?.id === projectId.value) ?? null;
if (storedProject?.scopes !== undefined) return storedProject;
if (fetchedProjectForPermissions.value?.id === projectId.value) {
return fetchedProjectForPermissions.value;
}
return storedProject;
});
const credentialPermissions = computed(() => {
@ -81,6 +91,17 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
}
}
async function ensureProjectPermissions() {
if (!projectId.value || projectForPermissions.value?.scopes !== undefined) return;
try {
const project = await projectsStore.fetchProject(projectId.value);
if (project.id === projectId.value) fetchedProjectForPermissions.value = project;
} catch {
// Keep permissions fail-closed when the project cannot be loaded.
}
}
async function fetchCredentials(integrations: ChatIntegrationDescriptor[]) {
loadedIntegrations.value = integrations;
credentialsLoading.value = true;
@ -111,6 +132,7 @@ export function useAgentChannelSetup(options: UseAgentChannelSetupOptions) {
async function loadChannelState(integrations: ChatIntegrationDescriptor[]) {
await Promise.all([
ensureProjectPermissions(),
options.fetchStatus(integrations.map((integration) => integration.type)),
fetchCredentials(integrations),
]);