From f1d87fddb2728e77e7a098f65cd54c1b61ae7ea8 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 4 Jun 2026 15:56:52 +0200 Subject: [PATCH] fix(editor): Show switch-to-static warning after connecting a private credential (#31712) Co-authored-by: Claude Opus 4.8 (1M context) --- .../CredentialEdit/CredentialEdit.test.ts | 67 +++++++++++++++++++ .../CredentialEdit/CredentialEdit.vue | 8 ++- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts index 014d516670a..05f67034d41 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.test.ts @@ -13,6 +13,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useNodeTypesStore } from '@/app/stores/nodeTypes.store'; import type { ICredentialsResponse } from '../../credentials.types'; import { within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; import type { ICredentialType, INode, INodeTypeDescription } from 'n8n-workflow'; vi.mock('vue-router', async () => ({ @@ -32,6 +33,17 @@ vi.mock('@/app/composables/useToast', () => ({ }), })); +const { confirmMock } = vi.hoisted(() => ({ confirmMock: vi.fn() })); + +vi.mock('@/app/composables/useMessage', () => ({ + useMessage: () => ({ confirm: confirmMock }), +})); + +vi.mock('@/features/resolvers/composables/useDynamicCredentials', async () => { + const { ref } = await vi.importActual('vue'); + return { useDynamicCredentials: () => ({ isEnabled: ref(true) }) }; +}); + const oAuth2Api: ICredentialType = { name: 'oAuth2Api', displayName: 'OAuth2 API', @@ -946,5 +958,60 @@ describe('CredentialEdit', () => { await retry(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled()); await retry(() => expect(queryByTestId('oauth-not-connected-banner')).not.toBeVisible()); }); + + describe('switching a connected private credential to static', () => { + test('shows the confirmation modal when the current user just connected, even if the server count is stale', async () => { + confirmMock.mockResolvedValue('confirm'); + const pinia = createPiniaForBannerTest(); + // connectedByMe reflects the in-session connection; connectedUserCount is the + // stale server value (0) that does not yet include the current user. + const credentialsStore = setupOAuthCredential({ + isResolvable: true, + connectedByMe: true, + connectedUserCount: 0, + }); + + const { getByRole } = renderComponent({ + props: { + activeId: 'cred-banner', + modalName: CREDENTIAL_EDIT_MODAL_KEY, + mode: 'edit', + }, + pinia, + }); + + await retry(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled()); + + await userEvent.click(getByRole('switch')); + + await retry(() => expect(confirmMock).toHaveBeenCalled()); + expect(confirmMock.mock.calls[0][0]).toContain('1 user(s)'); + }); + + test('does not show the confirmation modal when no users are connected', async () => { + confirmMock.mockResolvedValue('confirm'); + const pinia = createPiniaForBannerTest(); + const credentialsStore = setupOAuthCredential({ + isResolvable: true, + connectedByMe: false, + connectedUserCount: 0, + }); + + const { getByRole } = renderComponent({ + props: { + activeId: 'cred-banner', + modalName: CREDENTIAL_EDIT_MODAL_KEY, + mode: 'edit', + }, + pinia, + }); + + await retry(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled()); + + await userEvent.click(getByRole('switch')); + + expect(confirmMock).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue index 77589243fa5..c1d0a028515 100644 --- a/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue +++ b/packages/frontend/editor-ui/src/features/credentials/components/CredentialEdit/CredentialEdit.vue @@ -785,8 +785,12 @@ async function onResolvableChange(value: boolean) { return; } } else if (isTogglingToStatic) { - // Private → Static: warn only when there are connected users to disconnect - const connectedUserCount = currentCredential.value?.connectedUserCount ?? 0; + // Private → Static: warn only when there are connected users to disconnect. + // `connectedUserCount` reflects the server state at modal-open and isn't + // refreshed when the current user connects within the same session, so fold + // in `connectedByMe` to make sure the warning still appears in that case. + const serverConnectedCount = currentCredential.value?.connectedUserCount ?? 0; + const connectedUserCount = Math.max(serverConnectedCount, connectedByMe.value ? 1 : 0); if (connectedUserCount > 0) { const confirmAction = await confirmModal('switchToStatic', { count: String(connectedUserCount),