feat(editor): Restore n8n credits settings link (#36435)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dawid Myslak
2026-08-18 10:53:09 +00:00
committed by GitHub
co-authored by Cursor
parent 12cae8cbd9
commit 9836cf52a3
9 changed files with 227 additions and 19 deletions
@@ -5159,6 +5159,7 @@
"settings.ai.confirm.message.builderDisabled": "Disabling data sending will reduce the effectiveness of AI features. Are you sure you want to proceed?",
"settings.ai.confirm.message.builderEnabled": "Disabling data sending will turn off the AI Workflow Builder and reduce the effectiveness of AI features. Are you sure you want to proceed?",
"settings.ai.confirm.confirmButtonText": "Yes, disable",
"settings.n8nCredits": "n8n credits",
"settings.n8nConnect": "n8n Connect",
"settings.n8nConnect.title": "n8n Connect",
"settings.n8nConnect.description": "The easy way to manage AI usage and cost",
@@ -14,6 +14,8 @@ import { usePersonalizedTemplatesV3Store } from '@/experiments/personalizedTempl
import type { Version } from '@n8n/rest-api-client/api/versions';
import { ABOUT_MODAL_KEY, WHATS_NEW_MODAL_KEY } from '@/app/constants';
const openTopUpMock = vi.hoisted(() => vi.fn());
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: vi.fn(() => ({ meta: {} })),
@@ -22,6 +24,10 @@ vi.mock('vue-router', () => ({
RouterLink: vi.fn(),
}));
vi.mock('@/app/composables/useAiGatewayTopUp', () => ({
useAiGatewayTopUp: () => ({ openTopUp: openTopUpMock }),
}));
let renderComponent: ReturnType<typeof createComponentRenderer>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let uiStore: MockedStore<typeof useUIStore>;
@@ -45,6 +51,7 @@ const mockVersion: Version = {
describe('MainSidebar', () => {
beforeEach(() => {
openTopUpMock.mockReset();
renderComponent = createComponentRenderer(MainSidebar, {
pinia: createTestingPinia(),
});
@@ -205,5 +212,24 @@ describe('MainSidebar', () => {
},
});
});
it('should open the top-up flow when n8n credits is selected', async () => {
settingsStore.settings = {
...defaultSettings,
aiGateway: {
enabled: true,
budget: 0,
cloudUbbEnabled: true,
},
};
const { getByText, findByText } = renderComponent();
getByText('Settings').click();
const creditsItem = await findByText('n8n credits');
creditsItem.click();
expect(openTopUpMock).toHaveBeenCalledWith({ source: 'settings_page' });
});
});
});
@@ -70,7 +70,7 @@ const {
toggleCollapse,
} = useSidebarLayout();
const { settingsItems } = useSettingsItems();
const { settingsItems, handleSettingsItemSelect } = useSettingsItems();
const { fetchWallet, isEnabled: isAiGatewayEnabled } = useAiGateway();
// Component data
@@ -297,6 +297,10 @@ const handleSelect = (key: string) => {
void pageRedirectionHelper.goToDashboard();
break;
}
case 'settings-n8n-connect': {
void handleSettingsItemSelect(key);
break;
}
case 'quickstart':
case 'docs':
case 'forum':
@@ -0,0 +1,38 @@
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import SettingsSidebar from './SettingsSidebar.vue';
const handleSettingsItemSelectMock = vi.hoisted(() => vi.fn());
vi.mock('../composables/useSettingsItems', () => ({
useSettingsItems: () => ({
settingsItems: ref([
{
id: 'settings-n8n-connect',
label: 'n8n credits',
},
]),
handleSettingsItemSelect: handleSettingsItemSelectMock,
}),
}));
vi.mock('../composables/useAiGateway', () => ({
useAiGateway: () => ({
fetchWallet: vi.fn(),
isEnabled: ref(false),
}),
}));
describe('SettingsSidebar', () => {
it('delegates settings item clicks', () => {
const renderComponent = createComponentRenderer(SettingsSidebar, {
pinia: createTestingPinia(),
});
const { getByText } = renderComponent();
getByText('n8n credits').click();
expect(handleSettingsItemSelectMock).toHaveBeenCalledWith('settings-n8n-connect');
});
});
@@ -17,7 +17,7 @@ const i18n = useI18n();
const rootStore = useRootStore();
const uiStore = useUIStore();
const { settingsItems } = useSettingsItems();
const { settingsItems, handleSettingsItemSelect } = useSettingsItems();
const { fetchWallet, isEnabled } = useAiGateway();
onMounted(() => {
@@ -34,7 +34,12 @@ onMounted(() => {
<N8nText bold>{{ i18n.baseText('settings') }}</N8nText>
</div>
<div :class="$style.items">
<N8nMenuItem v-for="item in settingsItems" :key="item.id" :item="item" />
<N8nMenuItem
v-for="item in settingsItems"
:key="item.id"
:item="item"
@click="handleSettingsItemSelect(item.id)"
/>
</div>
<div :class="$style.versionContainer">
<N8nLink size="small" @click="uiStore.openModal(ABOUT_MODAL_KEY)">
@@ -1,23 +1,34 @@
import { computed, ref } from 'vue';
import { useSettingsItems } from './useSettingsItems';
import { VIEWS } from '../constants';
const isAiGatewayCloudUbbEnabled = ref(false);
const isAiGatewayEnabled = ref(true);
const balance = ref<number>();
const openTopUpMock = vi.hoisted(() => vi.fn());
vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({})) }));
vi.mock('./useUserHelpers', () => ({
useUserHelpers: vi.fn(() => ({ canUserAccessRouteByName: vi.fn(() => true) })),
}));
vi.mock('./useAiGateway', () => ({
useAiGateway: vi.fn(() => ({ balance: computed(() => undefined) })),
useAiGateway: vi.fn(() => ({ balance: computed(() => balance.value) })),
}));
vi.mock('./useAiGatewayTopUp', () => ({
useAiGatewayTopUp: vi.fn(() => ({ openTopUp: openTopUpMock })),
}));
vi.mock('@n8n/i18n', () => ({ useI18n: vi.fn(() => ({ baseText: (key: string) => key })) }));
vi.mock('../stores/ui.store', () => ({ useUIStore: vi.fn(() => ({ settingsSidebarItems: [] })) }));
vi.mock('@n8n/stores/settings.store', () => ({
useSettingsStore: vi.fn(() => ({
isAiAssistantEnabled: false,
isAiGatewayEnabled: true,
isAiGatewayCloudUbbEnabled: isAiGatewayCloudUbbEnabled.value,
get isAiGatewayEnabled() {
return isAiGatewayEnabled.value;
},
get isAiGatewayCloudUbbEnabled() {
return isAiGatewayCloudUbbEnabled.value;
},
isPublicApiEnabled: false,
isQueueModeEnabled: false,
isModuleActive: vi.fn(() => false),
@@ -29,16 +40,61 @@ vi.mock('@/features/shared/envFeatureFlag/useEnvFeatureFlag', () => ({
}));
describe('useSettingsItems', () => {
it.each([
[false, true],
[true, false],
])('shows n8n Connect only when Cloud UBB is %s', (cloudUbbEnabled, visible) => {
isAiGatewayCloudUbbEnabled.value = cloudUbbEnabled;
beforeEach(() => {
vi.clearAllMocks();
isAiGatewayEnabled.value = true;
isAiGatewayCloudUbbEnabled.value = false;
balance.value = undefined;
});
it('links to the n8n Connect settings page for the legacy cohort', () => {
const item = useSettingsItems().settingsItems.value.find(
({ id }) => id === 'settings-n8n-connect',
);
expect(item).toMatchObject({
label: 'settings.n8nConnect',
route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
});
});
it('shows n8n credits with the balance and no internal route for Cloud UBB', () => {
isAiGatewayCloudUbbEnabled.value = true;
balance.value = 1.23;
const item = useSettingsItems().settingsItems.value.find(
({ id }) => id === 'settings-n8n-connect',
);
expect(item !== undefined).toBe(visible);
expect(item).toMatchObject({
label: 'settings.n8nCredits',
creditsTag: 'aiGateway.wallet.balanceRemaining',
});
expect(item?.route).toBeUndefined();
});
it('hides n8n credits when AI Gateway is disabled', () => {
isAiGatewayEnabled.value = false;
isAiGatewayCloudUbbEnabled.value = true;
const item = useSettingsItems().settingsItems.value.find(
({ id }) => id === 'settings-n8n-connect',
);
expect(item).toBeUndefined();
});
it('opens the top-up flow only for the Cloud UBB credits item', async () => {
const { handleSettingsItemSelect } = useSettingsItems();
await handleSettingsItemSelect('settings-n8n-connect');
expect(openTopUpMock).not.toHaveBeenCalled();
isAiGatewayCloudUbbEnabled.value = true;
await handleSettingsItemSelect('settings-users');
expect(openTopUpMock).not.toHaveBeenCalled();
await handleSettingsItemSelect('settings-n8n-connect');
expect(openTopUpMock).toHaveBeenCalledWith({ source: 'settings_page' });
});
});
@@ -1,6 +1,7 @@
import { useRouter } from 'vue-router';
import { useUserHelpers } from './useUserHelpers';
import { useAiGateway } from './useAiGateway';
import { useAiGatewayTopUp } from './useAiGatewayTopUp';
import { computed } from 'vue';
import type { IMenuItem } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@@ -18,6 +19,7 @@ export function useSettingsItems() {
const settingsStore = useSettingsStore();
const { canUserAccessRouteByName } = useUserHelpers(router);
const { balance } = useAiGateway();
const { openTopUp } = useAiGatewayTopUp();
const { check: envFeatureFlagCheck } = useEnvFeatureFlag();
const settingsItems = computed<IMenuItem[]>(() => {
@@ -58,13 +60,17 @@ export function useSettingsItems() {
{
id: 'settings-n8n-connect',
icon: 'plug-zap',
label: i18n.baseText('settings.n8nConnect'),
label: i18n.baseText(
settingsStore.isAiGatewayCloudUbbEnabled ? 'settings.n8nCredits' : 'settings.n8nConnect',
),
position: 'top',
available:
settingsStore.isAiGatewayEnabled &&
!settingsStore.isAiGatewayCloudUbbEnabled &&
canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS),
route: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
(settingsStore.isAiGatewayCloudUbbEnabled ||
canUserAccessRouteByName(VIEWS.AI_GATEWAY_SETTINGS)),
route: settingsStore.isAiGatewayCloudUbbEnabled
? undefined
: { to: { name: VIEWS.AI_GATEWAY_SETTINGS } },
creditsTag:
balance.value !== undefined
? i18n.baseText('aiGateway.wallet.balanceRemaining', {
@@ -207,5 +213,11 @@ export function useSettingsItems() {
const visibleSettingsItems = computed(() => settingsItems.value.filter((item) => item.available));
return { settingsItems: visibleSettingsItems };
const handleSettingsItemSelect = async (itemId: string) => {
if (itemId === 'settings-n8n-connect' && settingsStore.isAiGatewayCloudUbbEnabled) {
await openTopUp({ source: 'settings_page' });
}
};
return { settingsItems: visibleSettingsItems, handleSettingsItemSelect };
}
@@ -0,0 +1,61 @@
import { ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import ChatSidebar from './ChatSidebar.vue';
const handleSettingsItemSelectMock = vi.hoisted(() => vi.fn());
vi.mock('@/app/composables/useSettingsItems', () => ({
useSettingsItems: () => ({
settingsItems: ref([]),
handleSettingsItemSelect: handleSettingsItemSelectMock,
}),
}));
vi.mock('@/app/composables/useKeybindings', () => ({
useKeybindings: vi.fn(),
}));
vi.mock('@/app/composables/useSidebarLayout', () => ({
MAX_SIDEBAR_WIDTH: 500,
MIN_SIDEBAR_WIDTH: 100,
useSidebarLayout: () => ({
isCollapsed: ref(false),
isResizing: ref(false),
sidebarWidth: ref(200),
onResizeStart: vi.fn(),
onResize: vi.fn(),
onResizeEnd: vi.fn(),
toggleCollapse: vi.fn(),
}),
}));
describe('ChatSidebar', () => {
it('delegates settings item selections', () => {
const renderComponent = createComponentRenderer(ChatSidebar, {
pinia: createTestingPinia(),
global: {
stubs: {
N8nResizeWrapper: {
template: '<div><slot /></div>',
},
N8nScrollArea: {
template: '<div><slot /></div>',
},
MainSidebarHeader: true,
ChatSidebarContent: true,
BottomMenu: {
emits: ['select'],
template:
'<button data-test-id="select-credits" @click="$emit(\'select\', \'settings-n8n-connect\')" />',
},
},
},
});
const { getByTestId } = renderComponent();
getByTestId('select-credits').click();
expect(handleSettingsItemSelectMock).toHaveBeenCalledWith('settings-n8n-connect');
});
});
@@ -44,7 +44,7 @@ function openCommandBar(event: MouseEvent) {
});
}
const { settingsItems } = useSettingsItems();
const { settingsItems, handleSettingsItemSelect } = useSettingsItems();
const mainMenuItems = computed<IMenuItem[]>(() => [
{
@@ -96,7 +96,12 @@ const onLogout = () => {
<N8nScrollArea as-child>
<div :class="$style.scrollArea">
<ChatSidebarContent :is-collapsed="isCollapsed" />
<BottomMenu :items="visibleMenuItems" :is-collapsed="isCollapsed" @logout="onLogout" />
<BottomMenu
:items="visibleMenuItems"
:is-collapsed="isCollapsed"
@select="handleSettingsItemSelect"
@logout="onLogout"
/>
</div>
</N8nScrollArea>
</N8nResizeWrapper>