+
+
+ {{ title }}
+
+
+
+
{{ title }}
@@ -104,6 +110,12 @@ if (import.meta.env.DEV) {
letter-spacing: var(--letter-spacing--tight);
}
+.titleRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing--2xs);
+}
+
.description {
margin: 0;
display: inline;
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 58c5c2ec23b..a634957804f 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -5008,7 +5008,8 @@
"settings.n8nConnect.wallet.topUp": "Top up balance",
"settings.n8nConnect.usage.refresh.tooltip": "Refresh usage records",
"settings.n8nAgent": "AI Assistant",
- "settings.n8nAgent.description": "Configure AI Assistant for everyone on this instance",
+ "settings.n8nAgent.description": "Control how the AI Assistant runs on this instance and what data it can use.",
+ "settings.n8nAgent.docsLabel": "AI Assistant docs",
"settings.agentBuilder.title": "Agents",
"settings.agentBuilder.description": "Choose which model the agent builder runs on. By default it uses the n8n AI assistant; admins can switch to a custom provider and credential.",
"settings.agentBuilder.section.model": "Builder model",
@@ -5035,46 +5036,95 @@
"agents.modelSelector.freeCredits.badge": "free credits",
"agents.modelSelector.freeCredits.description": "Get {credits} free OpenAI API credits. Try it with gpt-5-mini.",
"settings.n8nAgent.enable.label": "Enable AI Assistant",
- "settings.n8nAgent.enable.description": "Enables the feature for all users in the instance",
- "settings.n8nAgent.status.label": "AI Assistant status",
- "settings.n8nAgent.status.description": "Let users chat with AI Assistant to build and manage workflows",
+ "settings.n8nAgent.enable.description": "When enabled, everyone on this instance can use it.",
"settings.n8nAgent.status.enabled": "Enabled",
+ "settings.n8nAgent.status.setupRequired": "Setup required",
+ "settings.n8nAgent.status.enable": "Enable",
"settings.n8nAgent.status.manage": "Manage AI Assistant status",
"settings.n8nAgent.status.disable": "Disable",
"settings.n8nAgent.status.disable.title": "Disable AI Assistant?",
"settings.n8nAgent.status.disable.description": "This prevents everyone on this instance from using AI Assistant. Existing conversations won't be deleted. You can turn it back on later.",
- "settings.n8nAgent.empty.title": "Build and manage workflows with AI Assistant",
- "settings.n8nAgent.empty.description": "Let users ask AI Assistant to create, update, and run workflows across this instance.",
+ "settings.n8nAgent.empty.title": "Build automations by describing them",
+ "settings.n8nAgent.empty.description": "Describe what you want to automate. The AI Assistant plans, builds, tests, and fixes workflows and AI agents in your projects.",
"settings.n8nAgent.empty.enable": "Enable AI Assistant",
- "settings.n8nAgent.credentials.label": "AI service credentials",
- "settings.n8nAgent.credentials.description": "Connect the model, sandbox, and web search services AI Assistant uses",
- "settings.n8nAgent.credentials.title": "AI service credentials",
- "settings.n8nAgent.credentials.pageDescription": "Create or select provider connections for the services AI Assistant uses. These connections aren't available in workflows.",
- "settings.n8nAgent.credentials.back": "Back to AI Assistant",
- "settings.n8nAgent.credentials.edit": "Edit",
- "settings.n8nAgent.credentials.model.title": "Model",
- "settings.n8nAgent.credentials.model.description": "Choose the model provider credential used for everyone on this instance",
- "settings.n8nAgent.credentials.unavailable.title": "Credential settings aren't available",
- "settings.n8nAgent.credentials.unavailable.description": "This deployment manages AI Assistant credentials outside n8n.",
+ "settings.n8nAgent.empty.footnote": "You control permissions, MCP access, and what data is sent to the model after enabling.",
"settings.n8nAgent.capabilities.title": "Capabilities",
- "settings.n8nAgent.capabilities.description": "Choose which tools users can connect to AI Assistant",
+ "settings.n8nAgent.capabilities.description": "The AI Assistant works without these, with fewer abilities.",
"settings.n8nAgent.computerUse.label": "Computer use",
"settings.n8nAgent.computerUse.description": "Let users connect AI Assistant to their computer to work with files and the browser",
- "settings.n8nAgent.computerUse.disabled.warning": "Computer use has been disabled by your administrator",
"settings.n8nAgent.browserUse.label": "Browser use",
"settings.n8nAgent.browserUse.description": "Let users connect AI Assistant to their browser to view pages and automate tasks",
- "settings.n8nAgent.mcpAccess.label": "MCP server access",
- "settings.n8nAgent.mcpAccess.description": "Let users extend AI Assistant with MCP servers from the registry",
- "settings.n8nAgent.modelCredential.label": "Model credential",
- "settings.n8nAgent.modelCredential.description": "Choose a provider credential or create one",
- "settings.n8nAgent.modelCredential.placeholder": "Select a credential",
- "settings.n8nAgent.modelCredential.none": "Use environment configuration",
- "settings.n8nAgent.modelCredential.createNew": "Create credential",
+ "settings.n8nAgent.mcp.title": "MCP servers",
+ "settings.n8nAgent.mcp.description": "MCP servers add tools to the AI Assistant, like internal APIs or third party apps.",
+ "settings.n8nAgent.mcpAccess.label": "Let users connect MCP servers",
+ "settings.n8nAgent.mcpAccess.description": "Anyone on this instance can connect servers from the MCP registry to the AI Assistant.",
+ "settings.n8nAgent.modelCredential.label": "Model",
+ "settings.n8nAgent.modelCredential.missing.description": "No model connected. The AI Assistant won't respond until you connect one.",
+ "settings.n8nAgent.modelCredential.env.description": "Configured via environment variables. Connect a provider to manage it here.",
+ "settings.n8nAgent.modelCredential.env.value": "Environment configuration",
+ "settings.n8nAgent.modelCredential.add": "Connect",
+ "settings.n8nAgent.modelCredential.field": "Provider",
+ "settings.n8nAgent.modelCredential.placeholder": "Select a provider",
+ "settings.n8nAgent.connection.none": "Use environment configuration",
+ "settings.n8nAgent.connection.noneNoEnv": "Not connected",
+ "settings.n8nAgent.modelName.label": "Model ID",
+ "settings.n8nAgent.modelName.placeholder": "Enter a model ID",
+ "settings.n8nAgent.setup.step": "Step {step} of {total}",
+ "settings.n8nAgent.setup.continue": "Continue",
+ "settings.n8nAgent.setup.skip": "Skip",
+ "settings.n8nAgent.modelDialog.title": "Model connection",
+ "settings.n8nAgent.modelDialog.description": "Connect a model to power the AI Assistant on this instance.",
+ "settings.n8nAgent.modelDialog.setupTitle": "Connect a model",
+ "settings.n8nAgent.modelDialog.setupDescription": "The model powers the AI Assistant on this instance.",
+ "settings.n8nAgent.sandboxDialog.setupTitle": "Connect a code sandbox",
+ "settings.n8nAgent.modelDialog.footnote": "Connection details are stored securely on this instance and can't be used in workflows.",
+ "settings.n8nAgent.sandbox.label": "Code sandbox",
+ "settings.n8nAgent.sandbox.set.description": "Runs and tests generated code, isolated from your instance.",
+ "settings.n8nAgent.sandbox.missing.description": "No sandbox set. The AI Assistant won't work until you add one.",
+ "settings.n8nAgent.sandbox.env.description": "Configured via environment variables. Connect a provider to manage it here.",
+ "settings.n8nAgent.sandbox.env.value": "Environment configuration",
+ "settings.n8nAgent.sandbox.add": "Add sandbox",
+ "settings.n8nAgent.sandboxDialog.title": "Code sandbox",
+ "settings.n8nAgent.sandboxDialog.description": "The AI Assistant runs and tests generated code in the sandbox, isolated from your instance.",
+ "settings.n8nAgent.sandboxCredential.apiKey": "API key",
+ "settings.n8nAgent.sandboxDialog.provider": "Provider",
+ "settings.n8nAgent.sandboxDialog.providerHint": "The default comes from your environment configuration.",
+ "settings.n8nAgent.search.label": "Web search",
+ "settings.n8nAgent.search.recommended": "Recommended",
+ "settings.n8nAgent.search.description": "Lets the AI Assistant research approved websites.",
+ "settings.n8nAgent.search.env.description": "Set via environment variables. Remove them to manage web search here.",
+ "settings.n8nAgent.search.env.value": "Environment configuration",
+ "settings.n8nAgent.search.setup": "Set up",
+ "settings.n8nAgent.searchDialog.title": "Set up web search",
+ "settings.n8nAgent.searchDialog.setupTitle": "Set up web search (optional)",
+ "settings.n8nAgent.searchDialog.description": "Connect a search provider. The AI Assistant asks for permission before it accesses a domain.",
+ "settings.n8nAgent.searchCredential.label": "Provider",
+ "settings.n8nAgent.searchCredential.placeholder": "Select a provider",
+ "settings.n8nAgent.toast.saved": "Settings saved",
+ "settings.n8nAgent.toast.errorTitle": "Settings error",
+ "settings.n8nAgent.toast.loadError": "Failed to load settings",
+ "settings.n8nAgent.toast.saveError": "Failed to save settings",
+ "settings.n8nAgent.toast.preferenceError": "Failed to save preference",
"settings.n8nAgent.permissions.title": "Permissions",
- "settings.n8nAgent.permissions.description": "Choose which actions AI Assistant can take automatically. These settings don't give users any extra permissions",
- "settings.n8nAgent.permissions.alwaysAllow": "Always allow",
- "settings.n8nAgent.permissions.needsApproval": "Needs approval",
- "settings.n8nAgent.permissions.blocked": "Blocked",
+ "settings.n8nAgent.permissions.description": "Choose which actions the AI Assistant asks about before doing. It can never do more than the user it's working for: it uses their n8n role and project access.",
+ "settings.n8nAgent.permissions.group.workflows": "Workflows",
+ "settings.n8nAgent.permissions.group.folders": "Folders",
+ "settings.n8nAgent.permissions.group.dataTables": "Data tables",
+ "settings.n8nAgent.permissions.group.credentials": "Credentials",
+ "settings.n8nAgent.permissions.group.system": "System",
+ "settings.n8nAgent.permissions.group.web": "Web",
+ "settings.n8nAgent.permissions.group.mcp": "MCP tools",
+ "settings.n8nAgent.permissions.group.default": "Default",
+ "settings.n8nAgent.permissions.group.exception": "1 exception",
+ "settings.n8nAgent.permissions.group.exceptions": "{count} exceptions",
+ "settings.n8nAgent.permissions.group.mcpDisabled": "Users can't connect MCP servers",
+ "settings.n8nAgent.dataSharing.title": "Data sent to the model",
+ "settings.n8nAgent.dataSharing.description": "What n8n sends when you use the AI Assistant. n8n never trains models on your data. What's sent to the model is covered by your provider's terms.",
+ "settings.n8nAgent.dataSharing.manage.label": "AI usage settings",
+ "settings.n8nAgent.dataSharing.manage.description": "Controls whether n8n sends field names, types, and actual data values.",
+ "settings.n8nAgent.permissions.alwaysAllow": "Allow",
+ "settings.n8nAgent.permissions.needsApproval": "Ask first",
+ "settings.n8nAgent.permissions.blocked": "Block",
"settings.n8nAgent.permissions.createWorkflow": "Create workflows",
"settings.n8nAgent.permissions.updateWorkflow": "Update workflows",
"settings.n8nAgent.permissions.runWorkflow": "Run workflows",
@@ -6565,7 +6615,6 @@
"instanceAi.domainAccess.deny": "Deny",
"instanceAi.webSearch.prompt": "AI Assistant wants to search the web",
"instanceAi.webSearch.allowThread": "Always allow",
- "instanceAi.settings.permissions.fetchUrl.label": "Fetch URLs without approval",
"instanceAi.credential.selected": "Credential selected",
"instanceAi.credential.required": "This action requires a credential",
"instanceAi.credential.allSelected": "All credentials configured",
@@ -6670,43 +6719,6 @@
"instanceAi.debug.threads.refresh": "Refresh",
"instanceAi.debug.threads.fetchError": "Failed to load thread data",
"instanceAi.debug.threads.messageCount": "{count} messages",
- "instanceAi.settings.title": "Settings",
- "instanceAi.settings.save": "Save",
- "instanceAi.settings.reset": "Reset",
- "instanceAi.settings.section.model": "Model",
- "instanceAi.settings.section.sandbox": "Sandbox",
- "instanceAi.settings.section.search": "Search",
- "instanceAi.settings.section.advanced": "Advanced",
- "instanceAi.settings.credential.label": "Credential",
- "instanceAi.settings.credential.placeholder": "Select a credential...",
- "instanceAi.settings.credential.none": "None (use environment variables)",
- "instanceAi.settings.credential.createNew": "+ Create new credential",
- "instanceAi.settings.credential.createBrave": "+ Create Brave Search credential",
- "instanceAi.settings.credential.createSearxng": "+ Create SearXNG credential",
- "instanceAi.settings.modelName.label": "Model name",
- "instanceAi.settings.modelName.placeholder": "e.g. claude-sonnet-4-5, gpt-4o",
- "instanceAi.settings.apiKey.set": "Set",
- "instanceAi.settings.apiKey.notSet": "Not set",
- "instanceAi.settings.sandboxEnabled.label": "Enable sandbox",
- "instanceAi.settings.sandboxProvider.label": "Sandbox provider",
- "instanceAi.settings.daytonaCredential.label": "Daytona credential",
- "instanceAi.settings.n8nSandboxCredential.label": "n8n sandbox API key credential",
- "instanceAi.settings.n8nSandbox.serviceUrlHint": "Set N8N_SANDBOX_SERVICE_URL in your environment to use the n8n Sandbox Service.",
- "instanceAi.settings.sandboxImage.label": "Sandbox Docker image",
- "instanceAi.settings.sandboxTimeout.label": "Sandbox timeout (ms)",
- "instanceAi.settings.searchCredential.label": "Search credential",
- "instanceAi.settings.browserMcp.label": "Chrome DevTools MCP",
- "instanceAi.settings.mcpServers.label": "MCP servers",
- "instanceAi.settings.mcpServers.placeholder": "name=url, comma-separated",
- "instanceAi.settings.section.permissions": "Permissions",
- "instanceAi.settings.permissions.description": "Toggle which actions require your approval before the agent executes them.",
- "instanceAi.settings.permissions.createWorkflow.label": "Create workflow without approval",
- "instanceAi.settings.permissions.updateWorkflow.label": "Update workflow without approval",
- "instanceAi.settings.permissions.runWorkflow.label": "Run workflow without approval",
- "instanceAi.settings.permissions.publishWorkflow.label": "Publish/unpublish workflow without approval",
- "instanceAi.settings.permissions.deleteWorkflow.label": "Delete workflow without approval",
- "instanceAi.settings.permissions.deleteCredential.label": "Delete credential without approval",
- "instanceAi.settings.permissions.deleteDataTable.label": "Delete data table without approval",
"instanceAi.builderCard.title": "Building workflow",
"instanceAi.builderCard.phase.researching": "Researching nodes",
"instanceAi.builderCard.phase.schemas": "Loading schemas",
@@ -7056,7 +7068,6 @@
"instanceAi.filesystem.category.computerUse": "Computer",
"instanceAi.filesystem.category.filesystem": "Files",
"instanceAi.filesystem.category.shell": "Shell",
- "instanceAi.settings.permissions.activateWorkflow.label": "Activate workflow without approval",
"instanceAi.tools.patch-workflow": "Updating workflow",
"agents.home.untitledAgent": "Untitled Agent",
"agents.relativeTime.justNow": "just now",
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts
index bee209e87b3..f05a19f219f 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts
@@ -1,18 +1,29 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { ref } from 'vue';
-import { fireEvent } from '@testing-library/vue';
+import { nextTick, ref } from 'vue';
+import { fireEvent, waitFor } from '@testing-library/vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
+import { VIEWS } from '@/app/constants';
import SettingsInstanceAiView from '../views/SettingsInstanceAiView.vue';
+import ConnectionDialog from '../components/settings/ConnectionDialog.vue';
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useSettingsStore } from '@/app/stores/settings.store';
+import { hasPermission } from '@/app/utils/rbac/permissions';
+import { useCredentialsStore } from '@/features/credentials/credentials.store';
+import { fetchInstanceModelCredentials, fetchSettings } from '../instanceAi.settings.api';
import type { FrontendModuleSettings } from '@n8n/api-types';
+import type { ICredentialType } from 'n8n-workflow';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({
baseText: (key: string) => key,
+ credText: () => ({
+ inputLabelDisplayName: (parameter: { displayName: string }) => parameter.displayName,
+ inputLabelDescription: () => '',
+ hint: () => '',
+ }),
}),
}));
@@ -20,6 +31,27 @@ vi.mock('@/app/composables/useDocumentTitle', () => ({
useDocumentTitle: () => ({ set: vi.fn() }),
}));
+vi.mock('../components/settings/ConnectionFields.vue', async () => {
+ const { defineComponent, h } = await import('vue');
+ return {
+ default: defineComponent({
+ props: { data: { type: Object, required: true } },
+ emits: ['update'],
+ setup(props, { emit }) {
+ const input = (name: string, type = 'text') =>
+ h('input', {
+ 'data-test-id': `connection-${name}`,
+ type,
+ value: (props.data as Record)[name] ?? '',
+ onInput: (event: Event) =>
+ emit('update', name, (event.target as HTMLInputElement).value),
+ });
+ return () => h('div', [input('apiUrl'), input('apiKey', 'password')]);
+ },
+ }),
+ };
+});
+
vi.mock('@/app/stores/pushConnection.store', () => ({
usePushConnectionStore: vi.fn().mockReturnValue({
addEventListener: vi.fn(),
@@ -37,8 +69,8 @@ vi.mock('../instanceAi.settings.api', () => ({
localGatewayDisabled: false,
}),
updatePreferences: vi.fn(),
- fetchModelCredentials: vi.fn().mockResolvedValue([]),
fetchServiceCredentials: vi.fn().mockResolvedValue([]),
+ fetchInstanceModelCredentials: vi.fn().mockResolvedValue([]),
}));
vi.mock('../instanceAi.api', () => ({
@@ -50,16 +82,21 @@ vi.mock('@/app/utils/rbac/permissions', () => ({
hasPermission: vi.fn().mockReturnValue(true),
}));
-const { mcpConnectionsExperimentMock, computerUseExperimentMock, browserUseExperimentMock } =
- vi.hoisted(() => ({
- mcpConnectionsExperimentMock: vi.fn(),
- browserUseExperimentMock: vi.fn(),
- computerUseExperimentMock: vi.fn(),
- }));
+const {
+ mcpConnectionsExperimentMock,
+ computerUseExperimentMock,
+ browserUseExperimentMock,
+ routerPushMock,
+} = vi.hoisted(() => ({
+ mcpConnectionsExperimentMock: vi.fn(),
+ browserUseExperimentMock: vi.fn(),
+ computerUseExperimentMock: vi.fn(),
+ routerPushMock: vi.fn(),
+}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal()),
- useRouter: () => ({ push: vi.fn() }),
+ useRouter: () => ({ push: routerPushMock }),
}));
vi.mock('@/experiments/instanceAiMcpConnections', () => ({
@@ -75,6 +112,13 @@ vi.mock('@/experiments/instanceAiComputerUse', () => ({
}));
const renderComponent = createComponentRenderer(SettingsInstanceAiView);
+const renderConnectionDialog = createComponentRenderer(ConnectionDialog);
+const renderModelDialog = ({ props }: { props: Record }) =>
+ renderConnectionDialog({ props: { kind: 'model', ...props } });
+const renderSandboxDialog = ({ props }: { props: Record }) =>
+ renderConnectionDialog({ props: { kind: 'sandbox', ...props } });
+const renderSearchDialog = ({ props }: { props: Record }) =>
+ renderConnectionDialog({ props: { kind: 'search', ...props } });
function setModuleSettings(
settingsStore: ReturnType,
@@ -98,14 +142,60 @@ const defaultModuleSettings: NonNullable
describe('SettingsInstanceAiView', () => {
let store: ReturnType;
let settingsStore: ReturnType;
+ let fetchCredentialTypesSpy: ReturnType;
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(fetchSettings).mockResolvedValue(null as never);
+ vi.mocked(hasPermission).mockReturnValue(true);
mcpConnectionsExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) });
browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) });
computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) });
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
+ fetchCredentialTypesSpy = vi
+ .spyOn(useCredentialsStore(), 'fetchCredentialTypes')
+ .mockResolvedValue(undefined as never) as unknown as ReturnType;
+ useCredentialsStore().setCredentialTypes([
+ { name: 'openAiApi', displayName: 'OpenAI', properties: [] },
+ { name: 'anthropicApi', displayName: 'Anthropic', properties: [] },
+ {
+ name: 'daytonaApi',
+ displayName: 'Daytona',
+ properties: [
+ { displayName: 'API URL', name: 'apiUrl', type: 'string', required: true, default: '' },
+ {
+ displayName: 'API Key',
+ name: 'apiKey',
+ type: 'string',
+ typeOptions: { password: true },
+ required: true,
+ default: '',
+ },
+ ],
+ },
+ {
+ name: 'searXngApi',
+ displayName: 'SearXNG',
+ properties: [
+ { displayName: 'API URL', name: 'apiUrl', type: 'string', required: true, default: '' },
+ ],
+ },
+ {
+ name: 'braveSearchApi',
+ displayName: 'Brave Search',
+ properties: [
+ {
+ displayName: 'API Key',
+ name: 'apiKey',
+ type: 'string',
+ required: true,
+ default: '',
+ },
+ ],
+ test: { request: { url: '/search' } },
+ },
+ ] satisfies ICredentialType[]);
store = useInstanceAiSettingsStore();
settingsStore = useSettingsStore();
setModuleSettings(settingsStore, { ...defaultModuleSettings });
@@ -113,51 +203,925 @@ describe('SettingsInstanceAiView', () => {
settings: {
enabled: true,
permissions: {},
- mcpServers: '',
mcpAccessEnabled: true,
sandboxEnabled: false,
sandboxProvider: 'n8n-sandbox',
- sandboxImage: '',
- sandboxTimeout: 60,
daytonaCredentialId: null,
n8nSandboxCredentialId: null,
searchCredentialId: null,
+ modelCredentialId: null,
+ modelName: null,
+ modelEnvConfigured: false,
+ sandboxEnvConfigured: false,
+ searchEnvConfigured: false,
localGatewayDisabled: false,
},
});
});
- describe('env-only config', () => {
- it.each([
- ['model', 'instanceAi.settings.section.model'],
- ['search', 'instanceAi.settings.section.search'],
- ['sandbox', 'instanceAi.settings.section.sandbox'],
- ['advanced', 'instanceAi.settings.section.advanced'],
- ])('does not render the %s config section', (_name, titleKey) => {
- const { queryByText } = renderComponent();
- expect(queryByText(titleKey)).toBeNull();
+ describe('Model credential dialog', () => {
+ it('allows selecting an existing connection without the provider connection scope', async () => {
+ vi.mocked(hasPermission).mockImplementation(
+ (_permissionNames, options) => options?.rbac?.scope !== 'credential:manageInstance',
+ );
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ { id: 'anthropic-id', name: 'Backup', type: 'anthropicApi' },
+ ],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+
+ const { getByTestId, findByTestId, findByText, queryByTestId } = renderModelDialog({
+ props: { open: true },
+ });
+ const select = await findByTestId('n8n-agent-model-provider-select');
+
+ await waitFor(() => expect(select.querySelector('input')).not.toBeDisabled());
+ expect(queryByTestId('n8n-agent-model-connection-fields')).toBeNull();
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Backup · Anthropic'));
+ const modelNameField = getByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'claude-sonnet-4');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ expect(store.draft).toMatchObject({
+ modelCredentialId: 'anthropic-id',
+ modelName: 'claude-sonnet-4',
+ });
+ expect(save).toHaveBeenCalledOnce();
});
- it('renders the Permissions section', () => {
- const { getByText } = renderComponent();
- expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
+ it('allows provider connection managers to select an existing connection', async () => {
+ store.$patch({
+ instanceModelCredentials: [{ id: 'anthropic-id', name: 'Backup', type: 'anthropicApi' }],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText, queryByTestId } = renderModelDialog({
+ props: { open: true },
+ });
+ const select = await findByTestId('n8n-agent-model-provider-select');
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Backup · Anthropic'));
+ expect(queryByTestId('n8n-agent-model-connection-fields')).toBeNull();
+ const modelNameField = getByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'claude-sonnet-4');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ expect(store.draft).toMatchObject({
+ modelCredentialId: 'anthropic-id',
+ modelName: 'claude-sonnet-4',
+ });
+ expect(save).toHaveBeenCalledOnce();
});
- it('renders the enabled status action', () => {
- const { getByTestId } = renderComponent();
- expect(getByTestId('n8n-agent-status-menu')).toBeVisible();
+ it('stages an inline connection and only saves once a model name is committed', async () => {
+ const credentialsStore = useCredentialsStore();
+ vi.spyOn(credentialsStore, 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ const testCredential = vi.spyOn(credentialsStore, 'testCredential');
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText } = renderModelDialog({
+ props: { open: true },
+ });
+
+ const select = await findByTestId('n8n-agent-model-provider-select');
+ await waitFor(() => expect(select.querySelector('input')!.value).toBe('OpenAI'));
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Anthropic'));
+
+ const saveButton = getByTestId('n8n-agent-model-dialog-save');
+ expect(saveButton).toBeDisabled();
+ expect(save).not.toHaveBeenCalled();
+
+ const modelNameField = getByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'claude-sonnet-4');
+ await fireEvent.click(saveButton);
+
+ expect(store.draft).toMatchObject({
+ modelConnection: { type: 'anthropicApi' },
+ modelName: 'claude-sonnet-4',
+ });
+ expect(save).toHaveBeenCalledOnce();
+ expect(testCredential).not.toHaveBeenCalled();
+ });
+
+ it('restores the hydrated model name when switching back to the hydrated provider', async () => {
+ vi.spyOn(useCredentialsStore(), 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ const { findByTestId, findByText, getByTestId } = renderModelDialog({
+ props: { open: true },
+ });
+
+ const select = await findByTestId('n8n-agent-model-provider-select');
+ await waitFor(() => expect(select.querySelector('input')!.value).toBe('OpenAI'));
+
+ const modelNameInput = () => {
+ const field = getByTestId('n8n-agent-model-name-input');
+ return field.tagName === 'INPUT'
+ ? (field as HTMLInputElement)
+ : field.querySelector('input')!;
+ };
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Anthropic'));
+ expect(modelNameInput().value).toBe('');
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('OpenAI'));
+ expect(modelNameInput().value).toBe('gpt-4o');
+ });
+
+ it('keeps the dialog open when the saved credential fails its test', async () => {
+ const credentialsStore = useCredentialsStore();
+ credentialsStore.setCredentialTypes([
+ {
+ name: 'openAiApi',
+ displayName: 'OpenAI',
+ properties: [],
+ test: { request: { url: '/models' } },
+ },
+ ] satisfies ICredentialType[]);
+ vi.spyOn(credentialsStore, 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ const testCredential = vi.spyOn(credentialsStore, 'testCredential').mockResolvedValue({
+ status: 'Error',
+ message: 'Invalid API key',
+ });
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { findByTestId, getByTestId } = renderModelDialog({ props: { open: true } });
+
+ const modelNameField = await findByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'gpt-4.1');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ expect(await findByTestId('n8n-agent-model-credential-test-error')).toBeVisible();
+ expect(testCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'openai-id', type: 'openAiApi' }),
+ );
+ expect(save).not.toHaveBeenCalled();
+ });
+
+ it('pre-tests a selected existing connection and blocks the save on failure', async () => {
+ const credentialsStore = useCredentialsStore();
+ credentialsStore.setCredentialTypes([
+ {
+ name: 'anthropicApi',
+ displayName: 'Anthropic',
+ properties: [],
+ test: { request: { url: '/models' } },
+ },
+ ] satisfies ICredentialType[]);
+ vi.spyOn(credentialsStore, 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ const testCredential = vi.spyOn(credentialsStore, 'testCredential').mockResolvedValue({
+ status: 'Error',
+ message: 'Invalid API key',
+ });
+ store.$patch({
+ instanceModelCredentials: [{ id: 'anthropic-id', name: 'Backup', type: 'anthropicApi' }],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText } = renderModelDialog({
+ props: { open: true },
+ });
+ const select = await findByTestId('n8n-agent-model-provider-select');
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Backup · Anthropic'));
+ const modelNameField = getByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'claude-sonnet-4');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ expect(await findByTestId('n8n-agent-model-credential-test-error')).toBeVisible();
+ expect(testCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'anthropic-id', type: 'anthropicApi' }),
+ );
+ expect(save).not.toHaveBeenCalled();
});
});
- describe('isEnabled fallback for non-admin users', () => {
- it('falls back to moduleSettings when store.settings is null', () => {
- store.$patch({ settings: null });
- setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: true });
+ describe('Sandbox credential dialog', () => {
+ it('preselects the environment option when the sandbox is env-configured', async () => {
+ store.$patch({
+ settings: { ...store.settings!, sandboxEnvConfigured: true },
+ });
+ const { getByTestId } = renderSandboxDialog({ props: { open: true } });
- const { getByText } = renderComponent();
- // When enabled, the local gateway section should render
+ await waitFor(() =>
+ expect(getByTestId('n8n-agent-sandbox-provider-select').querySelector('input')!.value).toBe(
+ 'settings.n8nAgent.connection.none',
+ ),
+ );
+ });
+
+ it('keeps the setup action disabled in read-only mode until a connection is selected', async () => {
+ vi.mocked(hasPermission).mockImplementation(
+ (_permissionNames, options) => options?.rbac?.scope !== 'credential:manageInstance',
+ );
+ store.$patch({
+ serviceCredentials: [
+ {
+ id: 'daytona-id',
+ name: 'Existing Daytona',
+ type: 'daytonaApi',
+ },
+ ],
+ });
+ const { findByTestId, findByText, getByTestId } = renderSandboxDialog({
+ props: { open: true, setup: true },
+ });
+
+ const select = await findByTestId('n8n-agent-sandbox-provider-select');
+ expect(getByTestId('n8n-agent-sandbox-dialog-save')).toBeDisabled();
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Existing Daytona · Daytona'));
+
+ expect(getByTestId('n8n-agent-sandbox-dialog-save')).not.toBeDisabled();
+ });
+
+ it('clears both sandbox slots when switching back to the environment configuration', async () => {
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ daytonaCredentialId: 'daytona-id',
+ sandboxProvider: 'daytona',
+ sandboxEnvConfigured: true,
+ },
+ serviceCredentials: [
+ {
+ id: 'daytona-id',
+ name: 'Existing Daytona',
+ type: 'daytonaApi',
+ },
+ ],
+ });
+ vi.spyOn(useCredentialsStore(), 'getCredentialData').mockResolvedValue({
+ data: { apiUrl: 'https://app.daytona.io/api', apiKey: '__blank' },
+ } as never);
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { findByTestId, findByText, getByTestId } = renderSandboxDialog({
+ props: { open: true },
+ });
+
+ const select = await findByTestId('n8n-agent-sandbox-provider-select');
+ await waitFor(() => expect(select.querySelector('input')!).not.toBeDisabled());
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('settings.n8nAgent.connection.none'));
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+
+ expect(store.draft).toMatchObject({ sandboxConnection: null });
+ expect(save).toHaveBeenCalledOnce();
+ });
+
+ it('defaults the provider from settings and stages an inline Daytona connection', async () => {
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText } = renderSandboxDialog({
+ props: { open: true },
+ });
+
+ const providerSelect = await findByTestId('n8n-agent-sandbox-provider-select');
+ await waitFor(() =>
+ expect(providerSelect.querySelector('input')!.value).toBe('n8n Sandbox Service'),
+ );
+
+ await fireEvent.click(providerSelect.querySelector('input')!);
+ await fireEvent.click(await findByText('Daytona'));
+ const fields = getByTestId('n8n-agent-sandbox-connection-fields');
+ await fireEvent.update(fields.querySelector('input[type="password"]')!, 'daytona-key');
+
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+
+ expect(store.draft).toMatchObject({
+ sandboxConnection: {
+ type: 'daytonaApi',
+ data: { apiUrl: 'https://app.daytona.io/api', apiKey: 'daytona-key' },
+ },
+ });
+ expect(save).toHaveBeenCalledOnce();
+ });
+
+ it('stages an n8n sandbox API key as an x-api-key header connection', async () => {
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId } = renderSandboxDialog({ props: { open: true } });
+
+ await waitFor(() =>
+ expect(getByTestId('n8n-agent-sandbox-provider-select').querySelector('input')!.value).toBe(
+ 'n8n Sandbox Service',
+ ),
+ );
+
+ const saveButton = getByTestId('n8n-agent-sandbox-dialog-save');
+ expect(saveButton).toBeDisabled();
+
+ const keyField = getByTestId('n8n-agent-sandbox-api-key-input');
+ const keyInput =
+ keyField.tagName === 'INPUT'
+ ? (keyField as HTMLInputElement)
+ : keyField.querySelector('input')!;
+ await fireEvent.update(keyInput, 'sk-test');
+ await fireEvent.click(saveButton);
+
+ expect(store.draft).toMatchObject({
+ sandboxConnection: {
+ type: 'httpHeaderAuth',
+ data: { name: 'x-api-key', value: 'sk-test' },
+ },
+ });
+ expect(save).toHaveBeenCalledOnce();
+ });
+
+ it('allows provider connection managers to select an existing connection', async () => {
+ store.$patch({
+ serviceCredentials: [
+ {
+ id: 'daytona-id',
+ name: 'Existing Daytona',
+ type: 'daytonaApi',
+ },
+ ],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText, queryByTestId } = renderSandboxDialog({
+ props: { open: true },
+ });
+ const select = await findByTestId('n8n-agent-sandbox-provider-select');
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Existing Daytona · Daytona'));
+ expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull();
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+
+ expect(store.draft).toMatchObject({
+ daytonaCredentialId: 'daytona-id',
+ n8nSandboxCredentialId: null,
+ sandboxProvider: 'daytona',
+ });
+ expect(save).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('Search credential dialog', () => {
+ it('preselects SearXNG when search is unconfigured', async () => {
+ const { getByTestId } = renderSearchDialog({ props: { open: true } });
+
+ await waitFor(() =>
+ expect(getByTestId('n8n-agent-search-provider-select').querySelector('input')!.value).toBe(
+ 'SearXNG',
+ ),
+ );
+ expect(getByTestId('n8n-agent-search-dialog-save')).toBeDisabled();
+ });
+
+ it('preselects the environment option when search is env-configured', async () => {
+ store.$patch({
+ settings: { ...store.settings!, searchEnvConfigured: true },
+ });
+ const { getByTestId } = renderSearchDialog({ props: { open: true } });
+
+ await waitFor(() =>
+ expect(getByTestId('n8n-agent-search-provider-select').querySelector('input')!.value).toBe(
+ 'settings.n8nAgent.connection.none',
+ ),
+ );
+ });
+
+ it('keeps the setup save action disabled until the preselected default is edited', async () => {
+ const { getByTestId, findByTestId, findByText } = renderSearchDialog({
+ props: { open: true, setup: true },
+ });
+
+ const providerSelect = await findByTestId('n8n-agent-search-provider-select');
+ await waitFor(() => expect(providerSelect.querySelector('input')!.value).toBe('SearXNG'));
+ expect(getByTestId('n8n-agent-search-dialog-save')).toBeDisabled();
+
+ await fireEvent.click(providerSelect.querySelector('input')!);
+ await fireEvent.click(await findByText('Brave Search'));
+
+ expect(getByTestId('n8n-agent-search-dialog-save')).toBeDisabled();
+ const fields = getByTestId('n8n-agent-search-connection-fields');
+ await fireEvent.update(
+ fields.querySelector('[data-test-id="connection-apiKey"]')!,
+ 'brave-key',
+ );
+
+ expect(getByTestId('n8n-agent-search-dialog-save')).not.toBeDisabled();
+ });
+
+ it('tests a changed connection before saving it', async () => {
+ const credentialsStore = useCredentialsStore();
+ vi.spyOn(credentialsStore, 'testCredential').mockResolvedValue({
+ status: 'Error',
+ message: 'Invalid API key',
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText } = renderSearchDialog({
+ props: { open: true },
+ });
+ const providerSelect = await findByTestId('n8n-agent-search-provider-select');
+
+ await fireEvent.click(providerSelect.querySelector('input')!);
+ await fireEvent.click(await findByText('Brave Search'));
+ await fireEvent.update(
+ getByTestId('n8n-agent-search-connection-fields').querySelector(
+ '[data-test-id="connection-apiKey"]',
+ )!,
+ 'brave-key',
+ );
+ await fireEvent.click(getByTestId('n8n-agent-search-dialog-save'));
+
+ expect(save).not.toHaveBeenCalled();
+ expect(getByTestId('n8n-agent-search-credential-test-error')).toBeVisible();
+ });
+
+ it('allows provider connection managers to select an existing connection', async () => {
+ const credentialsStore = useCredentialsStore();
+ vi.spyOn(credentialsStore, 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ const testCredential = vi
+ .spyOn(credentialsStore, 'testCredential')
+ .mockResolvedValue({ status: 'OK', message: 'ok' } as never);
+ store.$patch({
+ serviceCredentials: [
+ {
+ id: 'brave-id',
+ name: 'Existing search',
+ type: 'braveSearchApi',
+ },
+ ],
+ });
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, findByTestId, findByText, queryByTestId } = renderSearchDialog({
+ props: { open: true },
+ });
+ const select = await findByTestId('n8n-agent-search-provider-select');
+
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('Existing search · Brave Search'));
+ expect(queryByTestId('n8n-agent-search-connection-fields')).toBeNull();
+ await fireEvent.click(getByTestId('n8n-agent-search-dialog-save'));
+
+ await waitFor(() => expect(store.draft).toMatchObject({ searchCredentialId: 'brave-id' }));
+ expect(testCredential).toHaveBeenCalledWith(
+ expect.objectContaining({ id: 'brave-id', type: 'braveSearchApi' }),
+ );
+ expect(save).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe('setup chain', () => {
+ type ConnectionDraft = { type: string } | null | undefined;
+
+ beforeEach(() => {
+ vi.mocked(fetchInstanceModelCredentials).mockResolvedValue([
+ { id: 'openai-id', name: 'OpenAI account', type: 'openAiApi' },
+ ]);
+ store.$patch({
+ instanceModelCredentials: [{ id: 'openai-id', name: 'OpenAI account', type: 'openAiApi' }],
+ });
+ vi.spyOn(useCredentialsStore(), 'getCredentialData').mockResolvedValue({
+ data: {},
+ } as never);
+ vi.spyOn(store, 'refreshInstanceModelCredentials').mockResolvedValue(undefined);
+ vi.spyOn(store, 'refreshCredentials').mockResolvedValue(undefined);
+ // Mimics the backend: connection payloads become credential assignments.
+ vi.spyOn(store, 'save').mockImplementation(async () => {
+ const draft = { ...store.draft } as Record;
+ const patch: Record = {};
+ for (const [key, value] of Object.entries(draft)) {
+ if (!['modelConnection', 'sandboxConnection', 'searchConnection'].includes(key)) {
+ patch[key] = value;
+ }
+ }
+ const model = draft.modelConnection as ConnectionDraft;
+ if (model !== undefined) {
+ patch.modelCredentialId = model ? 'created-model-cred' : null;
+ if (model && !store.instanceModelCredentials.some((c) => c.id === 'created-model-cred')) {
+ store.instanceModelCredentials.push({
+ id: 'created-model-cred',
+ name: 'AI Assistant model',
+ type: model.type,
+ });
+ }
+ }
+ const sandbox = draft.sandboxConnection as ConnectionDraft;
+ if (sandbox !== undefined) {
+ patch.daytonaCredentialId =
+ sandbox?.type === 'daytonaApi' ? 'created-daytona-cred' : null;
+ patch.n8nSandboxCredentialId =
+ sandbox?.type === 'httpHeaderAuth' ? 'created-n8n-cred' : null;
+ if (sandbox) {
+ patch.sandboxProvider = sandbox.type === 'daytonaApi' ? 'daytona' : 'n8n-sandbox';
+ }
+ }
+ store.$patch({
+ settings: { ...store.settings!, ...patch },
+ });
+ for (const key of Object.keys(store.draft)) {
+ delete (store.draft as Record)[key];
+ }
+ return true;
+ });
+ });
+
+ async function completeModelStep(
+ findByTestId: (id: string) => Promise,
+ getByTestId: (id: string) => HTMLElement,
+ findByText: (text: string) => Promise,
+ ) {
+ const select = await findByTestId('n8n-agent-model-provider-select');
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(await findByText('OpenAI'));
+ const modelNameField = getByTestId('n8n-agent-model-name-input');
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'gpt-4.1');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+ }
+
+ async function completeSandboxStep(getByTestId: (id: string) => HTMLElement) {
+ const apiKeyField = getByTestId('n8n-agent-sandbox-api-key-input');
+ const apiKeyInput =
+ apiKeyField.tagName === 'INPUT'
+ ? (apiKeyField as HTMLInputElement)
+ : apiKeyField.querySelector('input')!;
+ await fireEvent.update(apiKeyInput, 'sk-test');
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+ }
+
+ it('chains model setup into the sandbox step when both are unconfigured', async () => {
+ const { findByTestId, findByText, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-add'));
+ expect(await findByTestId('n8n-agent-model-dialog-step')).toBeVisible();
+
+ await completeModelStep(findByTestId, getByTestId, findByText);
+
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ expect(queryByTestId('n8n-agent-sandbox-dialog-back')).toBeVisible();
+ });
+
+ it('enables only after the required setup completes', async () => {
+ const disabledSettings = { ...store.settings!, enabled: false };
+ store.$patch({ settings: disabledSettings });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(true);
+ const { findByTestId, findByText, getByRole, getByTestId } = renderComponent();
+
+ await fireEvent.click(getByRole('button', { name: 'settings.n8nAgent.empty.enable' }));
+ expect(await findByTestId('n8n-agent-model-dialog-step')).toBeVisible();
+ expect(persistEnabled).not.toHaveBeenCalled();
+
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ expect(persistEnabled).not.toHaveBeenCalled();
+
+ const apiKeyField = getByTestId('n8n-agent-sandbox-api-key-input');
+ const apiKeyInput =
+ apiKeyField.tagName === 'INPUT'
+ ? (apiKeyField as HTMLInputElement)
+ : apiKeyField.querySelector('input')!;
+ await fireEvent.update(apiKeyInput, 'sk-test');
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+
+ await waitFor(() => expect(persistEnabled).toHaveBeenCalledWith(true));
+ });
+
+ it('stays disabled when setup is cancelled', async () => {
+ const disabledSettings = { ...store.settings!, enabled: false };
+ store.$patch({ settings: disabledSettings });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(true);
+ const { findByTestId, getByRole, getByTestId } = renderComponent();
+
+ await fireEvent.click(getByRole('button', { name: 'settings.n8nAgent.empty.enable' }));
+ await findByTestId('n8n-agent-model-dialog-step');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-cancel'));
+
+ expect(persistEnabled).not.toHaveBeenCalled();
+ expect(store.settings?.enabled).toBe(false);
+ });
+
+ it('enables immediately when required setup is already complete', async () => {
+ const disabledSettings = {
+ ...store.settings!,
+ enabled: false,
+ modelEnvConfigured: true,
+ sandboxEnvConfigured: true,
+ };
+ store.$patch({ settings: disabledSettings });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(true);
+ const { getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-enable-button'));
+
+ expect(persistEnabled).toHaveBeenCalledWith(true);
+ });
+
+ it('retests a configured model credential before enabling', async () => {
+ const disabledSettings = {
+ ...store.settings!,
+ enabled: false,
+ modelCredentialId: 'openai-id',
+ modelName: 'gpt-4o',
+ sandboxEnvConfigured: true,
+ };
+ store.$patch({
+ settings: disabledSettings,
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+
+ const credentialsStore = useCredentialsStore();
+ credentialsStore.setCredentialTypes([
+ {
+ name: 'openAiApi',
+ displayName: 'OpenAI',
+ properties: [],
+ test: { request: { url: '/models' } },
+ },
+ ] satisfies ICredentialType[]);
+ vi.spyOn(credentialsStore, 'getCredentialData').mockResolvedValue({
+ data: { apiKey: '__blank' },
+ } as never);
+ const testCredential = vi
+ .spyOn(credentialsStore, 'testCredential')
+ .mockImplementationOnce(async ({ id }) => {
+ credentialsStore.credentialTestResults.set(id, 'error');
+ return { status: 'Error', message: 'Invalid API key' };
+ })
+ .mockImplementationOnce(async ({ id }) => {
+ credentialsStore.credentialTestResults.set(id, 'success');
+ return { status: 'OK', message: 'Connection successful' };
+ });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(true);
+ const { findByTestId, getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-enable-button'));
+
+ expect(await findByTestId('n8n-agent-model-credential-test-error')).toBeVisible();
+ expect(persistEnabled).not.toHaveBeenCalled();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ await waitFor(() => expect(persistEnabled).toHaveBeenCalledWith(true));
+ expect(testCredential).toHaveBeenCalledTimes(2);
+ });
+
+ it('lets the user go back to step one and continue without changes', async () => {
+ const { findByTestId, findByText, getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-add'));
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-back')).toBeVisible());
+
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-back'));
+ await waitFor(() => expect(getByTestId('n8n-agent-model-dialog-step')).toBeVisible());
+
+ const continueButton = getByTestId('n8n-agent-model-dialog-save');
+ await waitFor(() => expect(continueButton).not.toBeDisabled());
+ await fireEvent.click(continueButton);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ });
+
+ it('offers the optional search step after the sandbox step without gating enablement', async () => {
+ const disabledSettings = { ...store.settings!, enabled: false };
+ store.$patch({ settings: disabledSettings });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(true);
+ const { findByTestId, findByText, getByRole, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByRole('button', { name: 'settings.n8nAgent.empty.enable' }));
+ await findByTestId('n8n-agent-model-dialog-step');
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ await completeSandboxStep(getByTestId);
+
+ await waitFor(() => expect(persistEnabled).toHaveBeenCalledWith(true));
+ await waitFor(() => expect(getByTestId('n8n-agent-search-dialog-step')).toBeVisible());
+
+ await fireEvent.click(getByTestId('n8n-agent-search-dialog-skip'));
+ await waitFor(() => expect(queryByTestId('n8n-agent-search-dialog-step')).toBeNull());
+ expect(store.settings?.searchCredentialId).toBeNull();
+ });
+
+ it('does not offer the search step when enabling fails', async () => {
+ const disabledSettings = { ...store.settings!, enabled: false };
+ store.$patch({ settings: disabledSettings });
+ vi.mocked(fetchSettings).mockResolvedValue(disabledSettings);
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+ const persistEnabled = vi.spyOn(store, 'persistEnabled').mockResolvedValue(false);
+ const { findByTestId, findByText, getByRole, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByRole('button', { name: 'settings.n8nAgent.empty.enable' }));
+ await findByTestId('n8n-agent-model-dialog-step');
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ await completeSandboxStep(getByTestId);
+
+ await waitFor(() => expect(persistEnabled).toHaveBeenCalledWith(true));
+ await nextTick();
+ expect(queryByTestId('n8n-agent-search-dialog-step')).toBeNull();
+ });
+
+ it('keeps the setup chain when navigating back from search to model', async () => {
+ vi.mocked(fetchSettings).mockResolvedValue(store.settings!);
+ const { findByTestId, findByText, getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-add'));
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ await completeSandboxStep(getByTestId);
+ await waitFor(() => expect(getByTestId('n8n-agent-search-dialog-step')).toBeVisible());
+
+ await fireEvent.click(getByTestId('n8n-agent-search-dialog-back'));
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-back'));
+ await waitFor(() => expect(getByTestId('n8n-agent-model-dialog-step')).toBeVisible());
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ });
+
+ it('skips the search step when search is already configured', async () => {
+ store.$patch({
+ settings: { ...store.settings!, searchEnvConfigured: true },
+ });
+ vi.mocked(fetchSettings).mockResolvedValue(store.settings!);
+ const { findByTestId, findByText, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-add'));
+ await completeModelStep(findByTestId, getByTestId, findByText);
+ await waitFor(() => expect(getByTestId('n8n-agent-sandbox-dialog-step')).toBeVisible());
+ await completeSandboxStep(getByTestId);
+
+ await nextTick();
+ expect(queryByTestId('n8n-agent-search-dialog-step')).toBeNull();
+ });
+
+ it('opens a plain dialog when only the model is missing', async () => {
+ store.$patch({
+ settings: { ...store.settings!, sandboxEnvConfigured: true },
+ });
+ const { findByTestId, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-add'));
+
+ expect(await findByTestId('n8n-agent-model-provider-select')).toBeVisible();
+ expect(queryByTestId('n8n-agent-model-dialog-step')).toBeNull();
+ });
+
+ it('opens a plain sandbox dialog when only the sandbox is missing', async () => {
+ store.$patch({
+ settings: { ...store.settings!, modelEnvConfigured: true },
+ });
+ const { findByTestId, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-add'));
+
+ expect(await findByTestId('n8n-agent-sandbox-provider-select')).toBeVisible();
+ expect(queryByTestId('n8n-agent-sandbox-dialog-step')).toBeNull();
+ expect(queryByTestId('n8n-agent-sandbox-dialog-back')).toBeNull();
+ });
+
+ it('editing the configured model row never chains into the sandbox step', async () => {
+ const configured = {
+ ...store.settings!,
+ modelCredentialId: 'openai-id',
+ modelName: 'gpt-4o',
+ };
+ store.$patch({ settings: configured });
+ vi.mocked(fetchSettings).mockResolvedValue(configured);
+ const { findByTestId, getByTestId, queryByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-model-row'));
+ const modelNameField = await findByTestId('n8n-agent-model-name-input');
+ expect(queryByTestId('n8n-agent-model-dialog-step')).toBeNull();
+
+ const modelNameInput =
+ modelNameField.tagName === 'INPUT'
+ ? (modelNameField as HTMLInputElement)
+ : modelNameField.querySelector('input')!;
+ await fireEvent.update(modelNameInput, 'gpt-4o-mini');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ await nextTick();
+ expect(queryByTestId('n8n-agent-sandbox-dialog-step')).toBeNull();
+ });
+ });
+
+ describe('status row', () => {
+ it('renders the enabled status action', () => {
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ modelCredentialId: 'openai-id',
+ modelEnvConfigured: true,
+ sandboxEnvConfigured: true,
+ },
+ });
+ const { getByTestId, getByText } = renderComponent();
+ expect(getByTestId('n8n-agent-status-menu')).toBeVisible();
+ expect(getByText('settings.n8nAgent.status.enabled')).toBeVisible();
+ });
+
+ it('shows setup required for a credential-only legacy model assignment', () => {
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ modelCredentialId: 'openai-id',
+ modelName: null,
+ sandboxEnvConfigured: true,
+ },
+ });
+ const { getByTestId, getByText } = renderComponent();
+ expect(getByTestId('n8n-agent-status-menu')).toBeVisible();
+ expect(getByText('settings.n8nAgent.status.setupRequired')).toBeVisible();
+ });
+
+ it('shows an enable button with dimmed sections when disabled but configured', () => {
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ enabled: false,
+ modelCredentialId: 'openai-id',
+ modelName: 'gpt-4o',
+ },
+ });
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+
+ const { getByTestId, getByText, queryByTestId } = renderComponent();
+ expect(getByTestId('n8n-agent-enable-button')).toBeVisible();
+ expect(queryByTestId('n8n-agent-status-menu')).toBeNull();
expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
});
+ });
+
+ describe('empty state', () => {
+ it('shows the empty state when disabled and never configured', () => {
+ store.$patch({ settings: { ...store.settings!, enabled: false } });
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: false });
+
+ const { getByText, queryByText } = renderComponent();
+ expect(getByText('settings.n8nAgent.empty.title')).toBeVisible();
+ expect(queryByText('settings.n8nAgent.permissions.title')).toBeNull();
+ });
it('hides content when disabled via moduleSettings fallback', () => {
store.$patch({ settings: null });
@@ -166,6 +1130,81 @@ describe('SettingsInstanceAiView', () => {
const { queryByText } = renderComponent();
expect(queryByText('settings.n8nAgent.permissions.title')).toBeNull();
});
+
+ it('falls back to moduleSettings when store.settings is null', () => {
+ store.$patch({ settings: null });
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, enabled: true });
+
+ const { getByText } = renderComponent();
+ expect(getByText('settings.n8nAgent.permissions.title')).toBeVisible();
+ });
+ });
+
+ describe('credential rows', () => {
+ it('shows add buttons when nothing is configured', () => {
+ const { getByTestId } = renderComponent();
+ expect(getByTestId('n8n-agent-model-add')).toBeVisible();
+ expect(getByTestId('n8n-agent-sandbox-add')).toBeVisible();
+ expect(getByTestId('n8n-agent-search-setup')).toBeVisible();
+ });
+
+ it('shows the configured model value once a credential pair is set', () => {
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [{ id: 'openai-id', name: 'OpenAI', type: 'openAiApi' }],
+ });
+
+ const { getByText, queryByTestId } = renderComponent();
+ expect(queryByTestId('n8n-agent-model-add')).toBeNull();
+ expect(getByText('OpenAI · gpt-4o')).toBeVisible();
+ });
+
+ it('opens configuration for environment-managed search', async () => {
+ store.$patch({ settings: { ...store.settings!, searchEnvConfigured: true } });
+
+ const { getByText, getByTestId, findByTestId, queryByTestId } = renderComponent();
+ expect(queryByTestId('n8n-agent-search-setup')).toBeNull();
+ expect(getByText('settings.n8nAgent.search.env.value')).toBeVisible();
+
+ await fireEvent.click(getByTestId('n8n-agent-search-row'));
+ expect(await findByTestId('n8n-agent-search-provider-select')).toBeVisible();
+ });
+
+ it('fetches credential types on mount so deep links can render connection fields', () => {
+ renderComponent();
+
+ expect(fetchCredentialTypesSpy).toHaveBeenCalledWith(false);
+ });
+
+ it('hides credential rows on managed deployments', () => {
+ setModuleSettings(settingsStore, { ...defaultModuleSettings, proxyEnabled: true });
+
+ const { queryByTestId } = renderComponent();
+ expect(queryByTestId('n8n-agent-model-row')).toBeNull();
+ expect(queryByTestId('n8n-agent-sandbox-row')).toBeNull();
+ expect(queryByTestId('n8n-agent-search-row')).toBeNull();
+ });
+ });
+
+ describe('data sharing', () => {
+ it('links to the AI usage settings instead of duplicating its controls', async () => {
+ const { getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-data-sharing-row'));
+
+ expect(routerPushMock).toHaveBeenCalledWith({ name: VIEWS.AI_SETTINGS });
+ });
+
+ it('does not link without permission to manage AI usage', async () => {
+ vi.mocked(hasPermission).mockImplementation(
+ (_permissionNames, options) => options?.rbac?.scope !== 'aiAssistant:manage',
+ );
+ const { getByTestId } = renderComponent();
+
+ await fireEvent.click(getByTestId('n8n-agent-data-sharing-row'));
+
+ expect(routerPushMock).not.toHaveBeenCalled();
+ });
});
describe('Browser use settings', () => {
@@ -206,7 +1245,7 @@ describe('SettingsInstanceAiView', () => {
it('persists a change to the MCP access toggle', async () => {
const setField = vi.spyOn(store, 'setField');
- const save = vi.spyOn(store, 'save').mockResolvedValue();
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
const { getByTestId } = renderComponent();
await fireEvent.click(getByTestId('n8n-agent-mcp-access-toggle'));
@@ -215,16 +1254,21 @@ describe('SettingsInstanceAiView', () => {
expect(save).toHaveBeenCalled();
});
- it('shows the Execute MCP tools permission when MCP access is enabled', () => {
- const { getByTestId } = renderComponent();
- expect(getByTestId('n8n-agent-permission-executeMcpTool')).toBeVisible();
+ it('shows the Execute MCP tools permission when the group is expanded', async () => {
+ const { getByTestId, getByLabelText } = renderComponent();
+
+ await fireEvent.click(getByLabelText('Toggle settings.n8nAgent.permissions.group.mcp'));
+
+ await waitFor(() => expect(getByTestId('n8n-agent-permission-executeMcpTool')).toBeVisible());
});
- it('hides the Execute MCP tools permission when MCP access is disabled', () => {
+ it('locks the MCP permission group when MCP access is disabled', () => {
store.$patch({ settings: { ...store.settings!, mcpAccessEnabled: false } });
- const { queryByTestId } = renderComponent();
+ const { getByText, queryByTestId, queryByLabelText } = renderComponent();
+ expect(getByText('settings.n8nAgent.permissions.group.mcpDisabled')).toBeVisible();
+ expect(queryByLabelText('Toggle settings.n8nAgent.permissions.group.mcp')).toBeNull();
expect(queryByTestId('n8n-agent-permission-executeMcpTool')).toBeNull();
});
@@ -234,7 +1278,49 @@ describe('SettingsInstanceAiView', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('n8n-agent-mcp-access-toggle')).toBeNull();
- expect(queryByTestId('n8n-agent-permission-executeMcpTool')).toBeNull();
+ expect(queryByTestId('n8n-agent-permission-group-mcp')).toBeNull();
+ });
+ });
+
+ describe('Permissions groups', () => {
+ it('renders a row per permission group', () => {
+ const { getByTestId } = renderComponent();
+ for (const group of ['workflows', 'folders', 'dataTables', 'credentials', 'system', 'web']) {
+ expect(getByTestId(`n8n-agent-permission-group-${group}`)).toBeVisible();
+ }
+ });
+
+ it('summarises non-default permissions as exceptions', () => {
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ permissions: { createWorkflow: 'always_allow', deleteWorkflow: 'blocked' },
+ },
+ });
+
+ const { getByTestId } = renderComponent();
+ expect(getByTestId('n8n-agent-permission-group-workflows').textContent).toContain(
+ 'settings.n8nAgent.permissions.group.exceptions',
+ );
+ expect(getByTestId('n8n-agent-permission-group-folders').textContent).toContain(
+ 'settings.n8nAgent.permissions.group.default',
+ );
+ });
+
+ it('persists a permission change from an expanded group', async () => {
+ const setPermission = vi.spyOn(store, 'setPermission');
+ const save = vi.spyOn(store, 'save').mockResolvedValue(true);
+ const { getByTestId, getByLabelText, getAllByText } = renderComponent();
+
+ await fireEvent.click(getByLabelText('Toggle settings.n8nAgent.permissions.group.folders'));
+ await waitFor(() => expect(getByTestId('n8n-agent-permission-createFolder')).toBeVisible());
+
+ const select = getByTestId('n8n-agent-permission-createFolder');
+ await fireEvent.click(select.querySelector('input')!);
+ await fireEvent.click(getAllByText('settings.n8nAgent.permissions.alwaysAllow')[0]);
+
+ expect(setPermission).toHaveBeenCalledWith('createFolder', 'always_allow');
+ expect(save).toHaveBeenCalled();
});
});
});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts
index e41a5530da0..1b895e33aa6 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAiSettings.store.test.ts
@@ -34,8 +34,8 @@ const mockFetchSettings = vi.fn();
const mockUpdateSettings = vi.fn();
const mockFetchPreferences = vi.fn();
const mockUpdatePreferences = vi.fn();
-const mockFetchModelCredentials = vi.fn().mockResolvedValue([]);
const mockFetchServiceCredentials = vi.fn().mockResolvedValue([]);
+const mockFetchInstanceModelCredentials = vi.fn().mockResolvedValue([]);
const mockCreateGatewayLink = vi.fn();
const mockDisconnectGatewaySession = vi.fn();
@@ -44,8 +44,8 @@ vi.mock('../instanceAi.settings.api', () => ({
updateSettings: (...args: unknown[]) => mockUpdateSettings(...args),
fetchPreferences: (...args: unknown[]) => mockFetchPreferences(...args),
updatePreferences: (...args: unknown[]) => mockUpdatePreferences(...args),
- fetchModelCredentials: (...args: unknown[]) => mockFetchModelCredentials(...args),
fetchServiceCredentials: (...args: unknown[]) => mockFetchServiceCredentials(...args),
+ fetchInstanceModelCredentials: (...args: unknown[]) => mockFetchInstanceModelCredentials(...args),
}));
const mockGetGatewayStatus = vi.fn();
@@ -57,6 +57,7 @@ vi.mock('../instanceAi.api', () => ({
import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
import { useSettingsStore } from '@/app/stores/settings.store';
+import { hasPermission } from '@/app/utils/rbac/permissions';
type InstanceAiModuleSettings = NonNullable;
@@ -97,11 +98,34 @@ describe('useInstanceAiSettingsStore', () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.mocked(hasPermission).mockReturnValue(false);
setActivePinia(createPinia());
store = useInstanceAiSettingsStore();
settingsStore = useSettingsStore();
});
+ describe('permissions', () => {
+ it('checks related admin scopes independently', () => {
+ vi.mocked(hasPermission)
+ .mockReturnValueOnce(true)
+ .mockReturnValueOnce(false)
+ .mockReturnValueOnce(false);
+
+ expect(store.canManage).toBe(true);
+ expect(store.canManageAiUsage).toBe(false);
+ expect(store.canManageInstanceCredentials).toBe(false);
+ expect(hasPermission).toHaveBeenNthCalledWith(1, ['rbac'], {
+ rbac: { scope: 'instanceAi:manage' },
+ });
+ expect(hasPermission).toHaveBeenNthCalledWith(2, ['rbac'], {
+ rbac: { scope: 'aiAssistant:manage' },
+ });
+ expect(hasPermission).toHaveBeenNthCalledWith(3, ['rbac'], {
+ rbac: { scope: 'credential:manageInstance' },
+ });
+ });
+ });
+
describe('isInstanceAiDisabled', () => {
it('returns true when module settings has enabled=false', () => {
setModuleSettings(settingsStore, {
@@ -256,10 +280,9 @@ describe('useInstanceAiSettingsStore', () => {
expect(store.isWorkflowBuilderAvailable).toBe(false);
expect(store.isSandboxEnabled).toBe(false);
- expect(store.sandboxUnavailableReason).toBeNull();
});
- it('exposes the sandbox unavailable reason from module settings', () => {
+ it('keeps the builder unavailable while the sandbox is enabled', () => {
setModuleSettings(settingsStore, {
sandboxEnabled: true,
workflowBuilderAvailable: false,
@@ -268,7 +291,51 @@ describe('useInstanceAiSettingsStore', () => {
expect(store.isWorkflowBuilderAvailable).toBe(false);
expect(store.isSandboxEnabled).toBe(true);
- expect(store.sandboxUnavailableReason).toBe('N8N_SANDBOX_SERVICE_URL is required.');
+ });
+ });
+
+ describe('settings persistence', () => {
+ const response = {
+ enabled: true,
+ permissions: {},
+ mcpServers: '',
+ mcpAccessEnabled: true,
+ sandboxEnabled: false,
+ sandboxProvider: 'n8n-sandbox',
+ sandboxImage: '',
+ sandboxTimeout: 60,
+ daytonaCredentialId: null,
+ n8nSandboxCredentialId: null,
+ searchCredentialId: null,
+ modelCredentialId: null,
+ modelName: null,
+ modelEnvConfigured: false,
+ sandboxEnvConfigured: false,
+ searchEnvConfigured: false,
+ localGatewayDisabled: false,
+ };
+
+ beforeEach(() => {
+ setModuleSettings(settingsStore, { enabled: false });
+ mockUpdateSettings.mockResolvedValue(response);
+ settingsStore.getModuleSettings = vi.fn().mockRejectedValue(new Error('refresh failed'));
+ });
+
+ it('keeps a settings save successful when the module refresh fails', async () => {
+ store.setField('mcpAccessEnabled', true);
+
+ await expect(store.save()).resolves.toBe(true);
+
+ expect(store.settings).toEqual(response);
+ expect(store.draft).toEqual({});
+ expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
+ });
+
+ it('keeps an enablement save successful when the module refresh fails', async () => {
+ await expect(store.persistEnabled(true)).resolves.toBe(true);
+
+ expect(store.settings).toEqual(response);
+ expect(settingsStore.moduleSettings['instance-ai']?.enabled).toBe(true);
});
});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/AdvancedSection.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/AdvancedSection.vue
deleted file mode 100644
index b4d633c52f3..00000000000
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/AdvancedSection.vue
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
- {{ i18n.baseText('instanceAi.settings.section.advanced') }}
-
-
-
-
-
-
-
-
-
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionDialog.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionDialog.vue
new file mode 100644
index 00000000000..a00bdc03641
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionDialog.vue
@@ -0,0 +1,691 @@
+
+
+
+
+
+
+ {{ stepLabel }}
+
+ {{ title }}
+ {{ description }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText(copy.providerHintKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText(copy.footnoteKey) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue
new file mode 100644
index 00000000000..12e0de3d76d
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ConnectionFields.vue
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ModelSection.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ModelSection.vue
deleted file mode 100644
index 0e9c93f207e..00000000000
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/ModelSection.vue
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
- {{ i18n.baseText('instanceAi.settings.section.model') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSection.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSection.vue
deleted file mode 100644
index 4a87295d7e7..00000000000
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SandboxSection.vue
+++ /dev/null
@@ -1,259 +0,0 @@
-
-
-
-
-
- {{ i18n.baseText('instanceAi.settings.section.sandbox') }}
-
-
-
- {{
- i18n.baseText('instanceAi.settings.sandboxEnabled.label')
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ i18n.baseText('instanceAi.settings.n8nSandbox.serviceUrlHint' as BaseTextKey) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SearchSection.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SearchSection.vue
deleted file mode 100644
index eda680c2918..00000000000
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/SearchSection.vue
+++ /dev/null
@@ -1,101 +0,0 @@
-
-
-
-
-
- {{ i18n.baseText('instanceAi.settings.section.search') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts
new file mode 100644
index 00000000000..323ef8398a1
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/__tests__/ConnectionDialog.test.ts
@@ -0,0 +1,312 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { fireEvent, waitFor } from '@testing-library/vue';
+import { createTestingPinia } from '@pinia/testing';
+import { setActivePinia } from 'pinia';
+import type { ICredentialType, INodeCredentialTestResult } from 'n8n-workflow';
+import { nextTick } from 'vue';
+import { createComponentRenderer } from '@/__tests__/render';
+import { useCredentialsStore } from '@/features/credentials/credentials.store';
+import ConnectionDialog from '../ConnectionDialog.vue';
+import { useInstanceAiSettingsStore } from '../../../instanceAiSettings.store';
+
+// Renders ConnectionFields and CredentialInputs for real (unlike the view suite, which mocks
+// ConnectionFields wholesale); only the parameter input leaf is stubbed.
+vi.mock('@/features/ndv/parameters/components/ParameterInputExpanded.vue', async () => {
+ const { defineComponent, h } = await import('vue');
+ return {
+ default: defineComponent({
+ props: { parameter: { type: Object, required: true }, value: { default: '' } },
+ emits: ['update'],
+ setup(props, { emit }) {
+ return () =>
+ h('input', {
+ 'data-test-id': `param-${(props.parameter as { name: string }).name}`,
+ value: String(props.value ?? ''),
+ onInput: (event: Event) =>
+ emit('update', {
+ name: (props.parameter as { name: string }).name,
+ value: (event.target as HTMLInputElement).value,
+ }),
+ });
+ },
+ }),
+ };
+});
+
+vi.mock('@n8n/i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useI18n: () => ({ baseText: (key: string) => key }),
+}));
+
+vi.mock('@/app/stores/pushConnection.store', () => ({
+ usePushConnectionStore: vi.fn().mockReturnValue({ addEventListener: vi.fn() }),
+}));
+
+vi.mock('../../../instanceAi.settings.api', () => ({
+ fetchSettings: vi.fn().mockResolvedValue(null),
+ updateSettings: vi.fn(),
+ fetchPreferences: vi.fn(),
+ updatePreferences: vi.fn(),
+ fetchServiceCredentials: vi.fn().mockResolvedValue([]),
+ fetchInstanceModelCredentials: vi.fn().mockResolvedValue([]),
+}));
+
+vi.mock('../../../instanceAi.api', () => ({
+ createGatewayLink: vi.fn(),
+ getGatewayStatus: vi.fn(),
+}));
+
+vi.mock('@/app/utils/rbac/permissions', () => ({
+ hasPermission: vi.fn().mockReturnValue(true),
+}));
+
+const renderDialog = createComponentRenderer(ConnectionDialog);
+
+const DAYTONA_TYPE: ICredentialType = {
+ name: 'daytonaApi',
+ displayName: 'Daytona',
+ properties: [
+ { displayName: 'API URL', name: 'apiUrl', type: 'string', required: true, default: '' },
+ {
+ displayName: 'API Key',
+ name: 'apiKey',
+ type: 'string',
+ typeOptions: { password: true },
+ required: true,
+ default: '',
+ },
+ ],
+ test: { request: { url: '/test' } },
+};
+
+const OPENAI_TYPE: ICredentialType = {
+ name: 'openAiApi',
+ displayName: 'OpenAI',
+ properties: [
+ { displayName: 'API Key', name: 'apiKey', type: 'string', required: true, default: '' },
+ { displayName: 'Organization ID', name: 'organizationId', type: 'string', default: '' },
+ { displayName: 'Add Custom Header', name: 'header', type: 'boolean', default: false },
+ {
+ displayName: 'Header Name',
+ name: 'headerName',
+ type: 'string',
+ default: '',
+ displayOptions: { show: { header: [true] } },
+ },
+ {
+ displayName: 'Header Value',
+ name: 'headerValue',
+ type: 'string',
+ default: '',
+ displayOptions: { show: { header: [true] } },
+ },
+ ],
+};
+
+describe('ConnectionDialog (real connection fields)', () => {
+ let store: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setActivePinia(createTestingPinia({ stubActions: false }));
+ store = useInstanceAiSettingsStore();
+ store.$patch({
+ settings: {
+ enabled: true,
+ permissions: {},
+ mcpAccessEnabled: true,
+ sandboxEnabled: false,
+ sandboxProvider: 'daytona',
+ daytonaCredentialId: null,
+ n8nSandboxCredentialId: null,
+ searchCredentialId: null,
+ modelCredentialId: null,
+ modelName: null,
+ modelEnvConfigured: false,
+ sandboxEnvConfigured: false,
+ searchEnvConfigured: false,
+ localGatewayDisabled: false,
+ },
+ });
+ });
+
+ it('renders an input per visible property of the selected credential type', async () => {
+ useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]);
+
+ const { findByTestId, getAllByTestId, getByTestId } = renderDialog({
+ props: { kind: 'sandbox', open: true },
+ });
+
+ await findByTestId('n8n-agent-sandbox-connection-fields');
+ await waitFor(() => expect(getAllByTestId('credential-connection-parameter')).toHaveLength(2));
+ expect(getByTestId('param-apiUrl')).toBeVisible();
+ expect(getByTestId('param-apiKey')).toBeVisible();
+ expect((getByTestId('param-apiUrl') as HTMLInputElement).value).toBe(
+ 'https://app.daytona.io/api',
+ );
+ });
+
+ it('renders no inputs when credential types are not loaded, so the view must fetch them', async () => {
+ const { findByTestId, queryAllByTestId, queryByTestId } = renderDialog({
+ props: { kind: 'sandbox', open: true },
+ });
+
+ await findByTestId('n8n-agent-sandbox-provider-select');
+ expect(queryByTestId('n8n-agent-sandbox-connection-fields')).toBeNull();
+ expect(queryAllByTestId('credential-connection-parameter')).toHaveLength(0);
+ });
+
+ it('keeps supported OpenAI organization and custom-header fields editable', async () => {
+ const credentialsStore = useCredentialsStore();
+ credentialsStore.setCredentialTypes([OPENAI_TYPE]);
+ vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
+ data: {
+ apiKey: 'stored-key',
+ organizationId: 'org-old',
+ header: true,
+ headerName: 'x-proxy-key',
+ headerValue: 'old-value',
+ },
+ } as never);
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ vi.mocked(store.save).mockResolvedValue(true);
+ const refresh = Promise.withResolvers();
+ vi.mocked(store.refreshInstanceModelCredentials).mockReturnValue(refresh.promise);
+
+ const { emitted, findByTestId, getByTestId } = renderDialog({
+ props: { kind: 'model', open: true },
+ });
+
+ await findByTestId('param-headerValue');
+ expect(getByTestId('param-organizationId')).toBeVisible();
+ expect(getByTestId('param-header')).toBeVisible();
+ expect(getByTestId('param-headerName')).toBeVisible();
+ await fireEvent.update(getByTestId('param-organizationId'), 'org-new');
+ await fireEvent.update(getByTestId('param-headerName'), 'x-new-proxy-key');
+ await fireEvent.update(getByTestId('param-headerValue'), 'new-value');
+ await fireEvent.click(getByTestId('n8n-agent-model-dialog-save'));
+
+ await waitFor(() => expect(store.save).toHaveBeenCalledOnce());
+ expect(emitted().saved).toBeUndefined();
+ expect(store.draft).toMatchObject({
+ modelConnection: {
+ type: 'openAiApi',
+ data: {
+ organizationId: 'org-new',
+ header: true,
+ headerName: 'x-new-proxy-key',
+ headerValue: 'new-value',
+ },
+ },
+ });
+ refresh.resolve();
+ await waitFor(() => expect(emitted().saved).toEqual([[]]));
+ });
+
+ it('hydrates the sandbox credential selected by the configured provider', async () => {
+ const credentialsStore = useCredentialsStore();
+ vi.mocked(credentialsStore.getCredentialData).mockResolvedValue({
+ data: { name: 'x-api-key', value: 'stored-key' },
+ } as never);
+ store.$patch({
+ settings: {
+ ...store.settings!,
+ sandboxProvider: 'n8n-sandbox',
+ daytonaCredentialId: 'daytona-id',
+ n8nSandboxCredentialId: 'n8n-id',
+ },
+ });
+ vi.mocked(store.save).mockResolvedValue(true);
+
+ const { findByTestId, getByTestId } = renderDialog({
+ props: { kind: 'sandbox', open: true },
+ });
+
+ const keyField = await findByTestId('n8n-agent-sandbox-api-key-input');
+ const keyInput =
+ keyField.tagName === 'INPUT'
+ ? (keyField as HTMLInputElement)
+ : keyField.querySelector('input')!;
+ expect(credentialsStore.getCredentialData).toHaveBeenCalledWith({ id: 'n8n-id' });
+ expect(keyInput.value).toBe('stored-key');
+ await fireEvent.update(keyInput, 'new-key');
+ await fireEvent.click(getByTestId('n8n-agent-sandbox-dialog-save'));
+
+ await waitFor(() => expect(store.save).toHaveBeenCalledOnce());
+ expect(store.draft).toMatchObject({
+ sandboxConnection: {
+ type: 'httpHeaderAuth',
+ data: { name: 'x-api-key', value: 'new-key' },
+ },
+ });
+ });
+
+ it('ignores a stale hydration after the dialog is reopened', async () => {
+ const credentialsStore = useCredentialsStore();
+ credentialsStore.setCredentialTypes([OPENAI_TYPE]);
+ store.$patch({
+ settings: { ...store.settings!, modelCredentialId: 'openai-id', modelName: 'gpt-4o' },
+ instanceModelCredentials: [
+ { id: 'openai-id', name: 'AI Assistant model', type: 'openAiApi' },
+ ],
+ });
+ const stale =
+ Promise.withResolvers>>();
+ const fresh =
+ Promise.withResolvers>>();
+ vi.mocked(credentialsStore.getCredentialData)
+ .mockReturnValueOnce(stale.promise)
+ .mockReturnValueOnce(fresh.promise);
+
+ const result = renderDialog({ props: { kind: 'model', open: true } });
+ await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(1));
+ await result.rerender({ kind: 'model', open: false });
+ await result.rerender({ kind: 'model', open: true });
+ await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2));
+
+ fresh.resolve({ data: { apiKey: 'fresh-key' } } as never);
+ const apiKeyInput = await result.findByTestId('param-apiKey');
+ expect((apiKeyInput as HTMLInputElement).value).toBe('fresh-key');
+ stale.resolve({ data: { apiKey: 'stale-key' } } as never);
+ await stale.promise;
+ await nextTick();
+ expect(result.getByTestId('param-apiKey')).toHaveValue('fresh-key');
+ });
+
+ it('keeps the dialog and fields locked while testing a connection', async () => {
+ useCredentialsStore().setCredentialTypes([DAYTONA_TYPE]);
+ const credentialsStore = useCredentialsStore();
+ let finishTest = (_result: INodeCredentialTestResult) => {};
+ vi.mocked(credentialsStore.testCredential).mockImplementation(
+ async () =>
+ await new Promise((resolve) => {
+ finishTest = resolve;
+ }),
+ );
+ vi.mocked(store.save).mockResolvedValue(true);
+
+ const { findByTestId, getByTestId } = renderDialog({
+ props: { kind: 'sandbox', open: true },
+ });
+ const apiKeyInput = await findByTestId('param-apiKey');
+ await fireEvent.update(apiKeyInput, 'secret');
+ const saveButton = getByTestId('n8n-agent-sandbox-dialog-save');
+ await waitFor(() => expect(saveButton).not.toBeDisabled());
+
+ await fireEvent.click(saveButton);
+
+ const cancelButton = getByTestId('n8n-agent-sandbox-dialog-cancel');
+ await waitFor(() => expect(cancelButton).toBeDisabled());
+ expect(apiKeyInput).toBeDisabled();
+ await fireEvent.click(cancelButton);
+ expect(getByTestId('n8n-agent-sandbox-dialog-cancel')).toBeDisabled();
+
+ finishTest({ status: 'OK', message: '' });
+ await waitFor(() => expect(store.save).toHaveBeenCalled());
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/useSettingsField.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/useSettingsField.ts
deleted file mode 100644
index 99e2f1d8a63..00000000000
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/settings/useSettingsField.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
-import type {
- InstanceAiAdminSettingsResponse,
- InstanceAiAdminSettingsUpdateRequest,
- InstanceAiUserPreferencesResponse,
- InstanceAiUserPreferencesUpdateRequest,
-} from '@n8n/api-types';
-
-type StringField = keyof {
- [K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends string
- ? K
- : never]: true;
-};
-type NumberField = keyof {
- [K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends number
- ? K
- : never]: true;
-};
-type BoolField = keyof {
- [K in keyof InstanceAiAdminSettingsResponse as InstanceAiAdminSettingsResponse[K] extends boolean
- ? K
- : never]: true;
-};
-
-type PreferenceStringField = keyof {
- [K in keyof InstanceAiUserPreferencesResponse as InstanceAiUserPreferencesResponse[K] extends string
- ? K
- : never]: true;
-};
-
-export function useSettingsField() {
- const store = useInstanceAiSettingsStore();
-
- function getString(key: StringField & keyof InstanceAiAdminSettingsUpdateRequest): string {
- const draftVal = store.draft[key];
- if (draftVal !== undefined) return String(draftVal);
- return store.settings?.[key] ?? '';
- }
-
- function getNumber(key: NumberField & keyof InstanceAiAdminSettingsUpdateRequest): number {
- const draftVal = store.draft[key];
- if (draftVal !== undefined) return Number(draftVal);
- return store.settings?.[key] ?? 0;
- }
-
- function getBool(key: BoolField & keyof InstanceAiAdminSettingsUpdateRequest): boolean {
- const draftVal = store.draft[key];
- if (draftVal !== undefined) return Boolean(draftVal);
- const fromSettings = store.settings?.[key];
- if (fromSettings !== undefined) return Boolean(fromSettings);
- if (key === 'enabled') return true;
- return false;
- }
-
- function getPreferenceString(
- key: PreferenceStringField & keyof InstanceAiUserPreferencesUpdateRequest,
- ): string {
- const draftVal = store.preferencesDraft[key];
- if (draftVal !== undefined) return String(draftVal);
- return store.preferences?.[key] ?? '';
- }
-
- return { store, getString, getNumber, getBool, getPreferenceString };
-}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts
new file mode 100644
index 00000000000..8bb181c72e0
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiSetupSteps.ts
@@ -0,0 +1,21 @@
+import { computed } from 'vue';
+import { useI18n } from '@n8n/i18n';
+import { useInstanceAiSettingsStore } from '../instanceAiSettings.store';
+
+/** Step labels for the setup wizard; the optional search step drops off when already configured. */
+export function useInstanceAiSetupSteps(step: number) {
+ const i18n = useI18n();
+ const store = useInstanceAiSettingsStore();
+
+ const totalSteps = computed(() =>
+ (store.settings?.searchCredentialId ?? store.settings?.searchEnvConfigured) ? 2 : 3,
+ );
+ const stepLabel = computed(() =>
+ i18n.baseText('settings.n8nAgent.setup.step', {
+ interpolate: { step, total: totalSteps.value },
+ }),
+ );
+ const isLastStep = computed(() => step >= totalSteps.value);
+
+ return { stepLabel, isLastStep };
+}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceCredentialTest.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceCredentialTest.ts
new file mode 100644
index 00000000000..722eb69ac29
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceCredentialTest.ts
@@ -0,0 +1,112 @@
+import { ref } from 'vue';
+import { i18n } from '@n8n/i18n';
+import {
+ displayParameter,
+ type ICredentialDataDecryptedObject,
+ type ICredentialsDecrypted,
+} from 'n8n-workflow';
+import { useCredentialsStore } from '@/features/credentials/credentials.store';
+import { useCredentialTestInBackground } from '@/features/credentials/composables/useCredentialTestInBackground';
+
+const genericError = () => i18n.baseText('instanceAi.workflowSetup.credentialTestFailedTooltip');
+
+export function useInstanceCredentialTest() {
+ const credentialsStore = useCredentialsStore();
+ const { isCredentialTypeTestable } = useCredentialTestInBackground();
+ const credentialTestError = ref('');
+ const isTestingCredential = ref(false);
+
+ function hasRequiredData(credentials: ICredentialsDecrypted): boolean {
+ const data = credentials.data ?? {};
+ return (credentialsStore.getCredentialTypeByName(credentials.type)?.properties ?? []).every(
+ (property) => {
+ if (!property.required || !displayParameter(data, property, null, null)) return true;
+ const value = data[property.name];
+ const hasValue =
+ typeof value === 'string'
+ ? value.trim().length > 0
+ : value !== null && value !== undefined;
+ return hasValue || (property.default !== undefined && property.default !== '');
+ },
+ );
+ }
+
+ async function runCredentialTest(credentials: ICredentialsDecrypted): Promise {
+ const result = await credentialsStore.testCredential(credentials);
+ if (result.status === 'OK') return true;
+
+ credentialTestError.value = result.message || genericError();
+ return false;
+ }
+
+ async function testCredential(credentials: ICredentialsDecrypted): Promise {
+ credentialTestError.value = '';
+ if (!isCredentialTypeTestable(credentials.type)) {
+ const isValid = hasRequiredData(credentials);
+ if (!isValid) credentialTestError.value = genericError();
+ return isValid;
+ }
+
+ isTestingCredential.value = true;
+ try {
+ return await runCredentialTest(credentials);
+ } catch {
+ credentialTestError.value = genericError();
+ return false;
+ } finally {
+ isTestingCredential.value = false;
+ }
+ }
+
+ // Results land in credentialsStore.credentialTestResults (keyed by credential id) so any
+ // consumer — view pre-flight or a dialog — can resurrect the failure via restoreStoredError.
+ async function testSavedCredential(id: string, name: string, type: string): Promise {
+ if (!isCredentialTypeTestable(type)) return true;
+
+ credentialTestError.value = '';
+ isTestingCredential.value = true;
+ try {
+ const credential = await credentialsStore.getCredentialData({ id });
+ const credentialData = credential && 'data' in credential ? credential.data : undefined;
+ if (!credentialData || typeof credentialData === 'string') throw new Error();
+
+ const {
+ ownedBy: _ownedBy,
+ sharedWithProjects: _sharedWithProjects,
+ oauthTokenData,
+ ...data
+ } = credentialData;
+ if (oauthTokenData) {
+ credentialsStore.credentialTestResults.set(id, 'success');
+ return true;
+ }
+
+ return await runCredentialTest({
+ id,
+ name,
+ type,
+ data: data as ICredentialDataDecryptedObject,
+ });
+ } catch {
+ credentialsStore.credentialTestResults.set(id, 'error');
+ credentialTestError.value = genericError();
+ return false;
+ } finally {
+ isTestingCredential.value = false;
+ }
+ }
+
+ /** Resurfaces a failure recorded for this credential by any prior testSavedCredential run. */
+ function restoreStoredError(id: string | null | undefined) {
+ credentialTestError.value =
+ id && credentialsStore.credentialTestResults.get(id) === 'error' ? genericError() : '';
+ }
+
+ return {
+ credentialTestError,
+ isTestingCredential,
+ testCredential,
+ testSavedCredential,
+ restoreStoredError,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
index 5af7104ad8a..e4e3db20389 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/constants.ts
@@ -7,6 +7,14 @@ export const INSTANCE_AI_CREDENTIALS_SETTINGS_VIEW = 'InstanceAiCredentialsSetti
export const INSTANCE_AI_PROJECT_ID_QUERY = 'projectId';
/** Entry-point source carried into the empty view when a hand-off can't create a thread yet. */
export const INSTANCE_AI_SOURCE_QUERY = 'source';
+
+/** Brand names, deliberately not translated; single source for dialogs and settings rows. */
+export const SANDBOX_PROVIDER_LABELS = {
+ daytona: 'Daytona',
+ 'n8n-sandbox': 'n8n Sandbox Service',
+} as const;
+
+export type InstanceAiConnectionKind = 'model' | 'sandbox' | 'search';
export const INSTANCE_AI_NEW_VIEW = 'InstanceAiNew';
export const INSTANCE_AI_AGENT_BUILDER_TARGET_METADATA_KEY = 'instanceAiAgentBuilderTarget';
export const NEW_CONVERSATION_TITLE = 'New conversation';
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts
index 2075edf6702..8bf7dacd968 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.settings.api.ts
@@ -5,7 +5,7 @@ import type {
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesResponse,
InstanceAiUserPreferencesUpdateRequest,
- InstanceAiModelCredential,
+ InstanceAiProviderConnection,
} from '@n8n/api-types';
export async function fetchSettings(
@@ -34,20 +34,14 @@ export async function updatePreferences(
return await makeRestApiRequest(context, 'PUT', '/instance-ai/preferences', body);
}
-export async function fetchModelCredentials(
- context: IRestApiContext,
-): Promise {
- return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/credentials');
-}
-
export async function fetchServiceCredentials(
context: IRestApiContext,
-): Promise {
+): Promise {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/service-credentials');
}
export async function fetchInstanceModelCredentials(
context: IRestApiContext,
-): Promise {
+): Promise {
return await makeRestApiRequest(context, 'GET', '/instance-ai/settings/model-credentials');
}
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts
index 1f06a708251..d5e82abb247 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiSettings.store.ts
@@ -9,7 +9,6 @@ import {
updateSettings,
fetchPreferences,
updatePreferences,
- fetchModelCredentials,
fetchServiceCredentials,
fetchInstanceModelCredentials,
} from './instanceAi.settings.api';
@@ -27,8 +26,7 @@ import type {
InstanceAiAdminSettingsResponse,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiUserPreferencesResponse,
- InstanceAiUserPreferencesUpdateRequest,
- InstanceAiModelCredential,
+ InstanceAiProviderConnection,
InstanceAiPermissions,
InstanceAiPermissionMode,
ToolCategory,
@@ -50,11 +48,9 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const isSaving = ref(false);
const settings = ref(null);
const preferences = ref(null);
- const credentials = ref([]);
- const serviceCredentials = ref([]);
- const instanceModelCredentials = ref([]);
+ const serviceCredentials = ref([]);
+ const instanceModelCredentials = ref([]);
const draft = reactive({});
- const preferencesDraft = reactive({});
// ── Gateway / daemon state ──────────────────────────────────────────
const HAS_CONNECTED_STORAGE_KEY = 'instanceAi.gateway.hasConnected';
@@ -99,7 +95,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const browserConnectUrl = ref(null);
const browserConnectUrlExpiresAt = ref(null);
let browserConnectUrlRequestId = 0;
- const activeDirectory = computed(() => gatewayDirectory.value);
const isInstanceAiDisabled = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.enabled !== true,
);
@@ -125,14 +120,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const isWorkflowBuilderAvailable = computed(
() => settingsStore.moduleSettings?.['instance-ai']?.workflowBuilderAvailable ?? true,
);
- const sandboxUnavailableReason = computed(
- () => settingsStore.moduleSettings?.['instance-ai']?.sandboxUnavailableReason ?? null,
- );
-
- const isDirty = computed(() => {
- if (!settings.value && !preferences.value) return false;
- return Object.keys(draft).length > 0 || Object.keys(preferencesDraft).length > 0;
- });
function syncInstanceAiFlagIntoGlobalModuleSettings(
adminRes: InstanceAiAdminSettingsResponse,
@@ -162,6 +149,12 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const canManage = computed(() =>
hasPermission(['rbac'], { rbac: { scope: 'instanceAi:manage' } }),
);
+ const canManageAiUsage = computed(() =>
+ hasPermission(['rbac'], { rbac: { scope: 'aiAssistant:manage' } }),
+ );
+ const canManageInstanceCredentials = computed(() =>
+ hasPermission(['rbac'], { rbac: { scope: 'credential:manageInstance' } }),
+ );
async function fetch(): Promise {
isLoading.value = true;
@@ -176,87 +169,74 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
const [s, p] = await Promise.all(promises);
settings.value = s;
preferences.value = p;
- if (!isProxyEnabled.value && !isCloudManaged.value) {
- const credPromises: [
- Promise,
- Promise,
- Promise,
- ] = [
- fetchModelCredentials(rootStore.restApiContext),
- canManage.value ? fetchServiceCredentials(rootStore.restApiContext) : Promise.resolve([]),
- canManage.value
- ? fetchInstanceModelCredentials(rootStore.restApiContext)
- : Promise.resolve([]),
- ];
- const [c, sc, imc] = await Promise.all(credPromises);
- credentials.value = c;
+ if (!isProxyEnabled.value && !isCloudManaged.value && canManage.value) {
+ const [sc, imc] = await Promise.all([
+ fetchServiceCredentials(rootStore.restApiContext),
+ fetchInstanceModelCredentials(rootStore.restApiContext),
+ ]);
serviceCredentials.value = sc;
instanceModelCredentials.value = imc;
}
clearDraft();
} catch {
- toast.showError(new Error('Failed to load settings'), 'Settings error');
+ toast.showError(
+ new Error(i18n.baseText('settings.n8nAgent.toast.loadError')),
+ i18n.baseText('settings.n8nAgent.toast.errorTitle'),
+ );
} finally {
isLoading.value = false;
}
}
- async function save(): Promise {
+ /**
+ * Persists the staged admin draft. Returns whether the save succeeded; on
+ * failure the draft is discarded so a later unrelated save can't flush it.
+ */
+ async function save(): Promise {
+ if (Object.keys(draft).length === 0) return true;
isSaving.value = true;
try {
- const hasAdminChanges = Object.keys(draft).length > 0;
- const hasPreferenceChanges = Object.keys(preferencesDraft).length > 0;
-
- const [adminResult, prefsResult] = await Promise.allSettled([
- hasAdminChanges
- ? updateSettings(rootStore.restApiContext, {
- ...toRaw(draft),
- } as InstanceAiAdminSettingsUpdateRequest)
- : Promise.resolve(settings.value),
- hasPreferenceChanges
- ? updatePreferences(rootStore.restApiContext, preferencesDraft)
- : Promise.resolve(preferences.value),
- ]);
-
- if (adminResult.status === 'fulfilled' && adminResult.value)
- settings.value = adminResult.value;
- if (prefsResult.status === 'fulfilled' && prefsResult.value)
- preferences.value = prefsResult.value;
-
- const failed = [adminResult, prefsResult].filter((r) => r.status === 'rejected');
- if (failed.length > 0) {
- throw (failed[0] as PromiseRejectedResult).reason;
- }
-
+ const result = await updateSettings(rootStore.restApiContext, {
+ ...toRaw(draft),
+ } as InstanceAiAdminSettingsUpdateRequest);
+ settings.value = result;
clearDraft();
- toast.showMessage({ title: 'Settings saved', type: 'success' });
- if (hasAdminChanges) {
- await settingsStore.getModuleSettings();
- const adminSaved =
- adminResult.status === 'fulfilled' && adminResult.value ? adminResult.value : null;
- if (adminSaved) {
- syncInstanceAiFlagIntoGlobalModuleSettings(adminSaved);
- }
- }
- } catch {
- toast.showError(new Error('Failed to save settings'), 'Settings error');
+ toast.showMessage({
+ title: i18n.baseText('settings.n8nAgent.toast.saved'),
+ type: 'success',
+ });
+ syncInstanceAiFlagIntoGlobalModuleSettings(result);
+ await settingsStore.getModuleSettings().catch(() => {});
+ return true;
+ } catch (error) {
+ clearDraft();
+ toast.showError(error, i18n.baseText('settings.n8nAgent.toast.errorTitle'));
+ return false;
} finally {
isSaving.value = false;
}
}
/** Persists only the Instance AI on/off flag (does not send other admin draft fields). */
- async function persistEnabled(value: boolean): Promise {
+ async function persistEnabled(value: boolean): Promise {
isSaving.value = true;
try {
const result = await updateSettings(rootStore.restApiContext, { enabled: value });
settings.value = result;
delete draft.enabled;
- await settingsStore.getModuleSettings();
syncInstanceAiFlagIntoGlobalModuleSettings(result);
- toast.showMessage({ title: 'Settings saved', type: 'success' });
+ await settingsStore.getModuleSettings().catch(() => {});
+ toast.showMessage({
+ title: i18n.baseText('settings.n8nAgent.toast.saved'),
+ type: 'success',
+ });
+ return true;
} catch {
- toast.showError(new Error('Failed to save settings'), 'Settings error');
+ toast.showError(
+ new Error(i18n.baseText('settings.n8nAgent.toast.saveError')),
+ i18n.baseText('settings.n8nAgent.toast.errorTitle'),
+ );
+ return false;
} finally {
isSaving.value = false;
}
@@ -269,7 +249,10 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
});
preferences.value = result;
} catch {
- toast.showError(new Error('Failed to save preference'), 'Settings error');
+ toast.showError(
+ new Error(i18n.baseText('settings.n8nAgent.toast.preferenceError')),
+ i18n.baseText('settings.n8nAgent.toast.errorTitle'),
+ );
}
}
@@ -363,23 +346,14 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
key: K,
value: InstanceAiAdminSettingsUpdateRequest[K],
): void {
- draft[key] = value;
- }
-
- function setPreferenceField(
- key: K,
- value: InstanceAiUserPreferencesUpdateRequest[K],
- ): void {
- preferencesDraft[key] = value;
+ if (value === undefined) delete draft[key];
+ else draft[key] = value;
}
function clearDraft(): void {
for (const key of Object.keys(draft)) {
delete (draft as Record)[key];
}
- for (const key of Object.keys(preferencesDraft)) {
- delete (preferencesDraft as Record)[key];
- }
}
function setPermission(key: keyof InstanceAiPermissions, value: InstanceAiPermissionMode): void {
@@ -393,10 +367,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
return settings.value?.permissions?.[key] ?? 'require_approval';
}
- function reset(): void {
- clearDraft();
- }
-
// ── Gateway status fetch ──────────────────────────────────────────────
async function fetchGatewayStatus(): Promise {
@@ -599,12 +569,7 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
async function refreshCredentials(): Promise {
if (isProxyEnabled.value) return;
try {
- const [c, sc] = await Promise.all([
- fetchModelCredentials(rootStore.restApiContext),
- fetchServiceCredentials(rootStore.restApiContext),
- ]);
- credentials.value = c;
- serviceCredentials.value = sc;
+ serviceCredentials.value = await fetchServiceCredentials(rootStore.restApiContext);
} catch {
// Silently fail — credentials list will refresh on next full fetch
}
@@ -633,26 +598,23 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
return {
canManage,
+ canManageAiUsage,
+ canManageInstanceCredentials,
settings,
preferences,
- credentials,
serviceCredentials,
instanceModelCredentials,
draft,
- preferencesDraft,
isLoading,
isSaving,
- isDirty,
fetch,
save,
persistEnabled,
persistLocalGatewayPreference,
ensurePreferencesLoaded,
setField,
- setPreferenceField,
setPermission,
getPermission,
- reset,
// Gateway / daemon
isDaemonConnecting,
setupCommand,
@@ -665,7 +627,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
gatewayDirectory,
gatewayHostIdentifier,
gatewayToolCategories,
- activeDirectory,
isInstanceAiDisabled,
isLocalGatewayDisabled,
isLocalGatewayDisabledByAdmin,
@@ -673,7 +634,6 @@ export const useInstanceAiSettingsStore = defineStore('instanceAiSettings', () =
isProxyEnabled,
isSandboxEnabled,
isWorkflowBuilderAvailable,
- sandboxUnavailableReason,
fetchGatewayStatus,
connectLocalGateway,
isCloudManaged,
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts
index ba78ee2b5e7..f0cf22d43d7 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts
@@ -160,6 +160,16 @@ export const InstanceAiModule: FrontendModuleDescription = {
},
},
},
+ // Permanent redirect from the removed `/settings/assistant/credentials` page.
+ {
+ path: 'assistant/credentials',
+ redirect: (to) => ({ name: INSTANCE_AI_SETTINGS_VIEW, query: to.query, hash: to.hash }),
+ meta: {
+ telemetry: {
+ pageCategory: 'settings',
+ },
+ },
+ },
],
projectTabs: {
overview: [],
diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue
index d7c0bf7c557..433dabb0a59 100644
--- a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue
+++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue
@@ -1,12 +1,14 @@
@@ -172,30 +414,52 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
+ :docs-url="DOCS_URL"
+ :docs-label="i18n.baseText('settings.n8nAgent.docsLabel')"
+ >
+
+
+
+
+ >
+
+
+ {{ i18n.baseText('settings.n8nAgent.empty.footnote') }}
+
+
+
-
-
+
+
+
-
- {{ i18n.baseText('settings.n8nAgent.status.enabled') }}
+
+ {{
+ isSetupRequired
+ ? i18n.baseText('settings.n8nAgent.status.setupRequired')
+ : i18n.baseText('settings.n8nAgent.status.enabled')
+ }}
@@ -231,35 +505,123 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
-
-
+
+
+ {{ i18n.baseText('settings.n8nAgent.modelCredential.label') }}
+
+
+ {{ i18n.baseText(modelDescription.key) }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText('settings.n8nAgent.sandbox.label') }}
+
+
+ {{ i18n.baseText(sandboxDescription.key) }}
+
+
+
+
+
+
+
+
+
+ {{ i18n.baseText('settings.n8nAgent.search.label') }}
+
+
+ {{ i18n.baseText('settings.n8nAgent.search.recommended') }}
+
+
+
+ {{
+ searchState === 'env'
+ ? i18n.baseText('settings.n8nAgent.search.env.description')
+ : i18n.baseText('settings.n8nAgent.search.description')
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+ {{ groupSummary(group) }}
+
+
+
+
+
+ {{ i18n.baseText(`settings.n8nAgent.permissions.${key}` as BaseTextKey) }}
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
@@ -374,13 +780,45 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: var(--radius--full);
+}
+
+.statusDotSuccess {
background: var(--text-color--success);
}
+.statusDotWarning {
+ background: var(--text-color--warning);
+}
+
.danger {
color: var(--text-color--danger);
}
+.titleWithTag {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing--2xs);
+}
+
+.dim {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.permissionList {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing--3xs);
+ padding: 0 0 var(--spacing--2xs) var(--spacing--sm);
+}
+
+.permissionRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--spacing--sm);
+}
+
.permissionSelect {
width: 11rem;
}