fix(editor): Close credential modal after successful setup (#31925)

Co-authored-by: Ricardo Espinoza <[email protected]>
This commit is contained in:
Svetoslav Dekov
2026-06-09 12:50:40 +00:00
committed by GitHub
co-authored by Ricardo Espinoza
parent 06df815c4b
commit 7b2c722182
10 changed files with 374 additions and 11 deletions
@@ -651,6 +651,7 @@ export type ModalState = {
export interface NewCredentialsModal extends ModalState {
showAuthSelector?: boolean;
forceManualMode?: boolean;
closeOnSave?: boolean;
projectId?: string;
suggestedName?: string;
nodeName?: string;
@@ -207,6 +207,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
mode: '',
activeId: null,
showAuthSelector: false,
closeOnSave: false,
} as ModalState,
[DELETE_FOLDER_MODAL_KEY]: {
open: false,
@@ -526,6 +527,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
...modalsById.value[CREDENTIAL_EDIT_MODAL_KEY],
projectId: undefined,
contextNode: undefined,
closeOnSave: false,
hideAskAssistant: options.hideAskAssistant,
} as NewCredentialsModal;
openModal(CREDENTIAL_EDIT_MODAL_KEY);
@@ -539,13 +541,14 @@ export const useUIStore = defineStore(STORES.UI, () => {
suggestedName?: string,
nodeName?: string,
contextNode?: INodeUi,
options: { hideAskAssistant?: boolean } = {},
options: { hideAskAssistant?: boolean; closeOnSave?: boolean } = {},
) => {
setActiveId(CREDENTIAL_EDIT_MODAL_KEY, type);
setShowAuthSelector(CREDENTIAL_EDIT_MODAL_KEY, showAuthOptions);
modalsById.value[CREDENTIAL_EDIT_MODAL_KEY] = {
...modalsById.value[CREDENTIAL_EDIT_MODAL_KEY],
forceManualMode,
closeOnSave: options.closeOnSave ?? false,
projectId,
suggestedName,
nodeName,
@@ -157,7 +157,16 @@ describe('InstanceAiCredentialSetup', () => {
});
await userEvent.click(getByTestId('instance-ai-credential-setup-button'));
expect(openNewCredSpy).toHaveBeenCalledWith('type1', false, false, undefined, undefined);
expect(openNewCredSpy).toHaveBeenCalledWith(
'type1',
false,
false,
undefined,
undefined,
undefined,
undefined,
{ closeOnSave: true },
);
});
it('renders a single credential with picker when existing credentials', () => {
@@ -204,7 +204,18 @@ const hasExistingCredentials = computed(() => {
function openNewCredentialModal() {
const req = currentRequest.value;
if (!req) return;
uiStore.openNewCredential(req.credentialType, false, false, props.projectId, req.suggestedName);
uiStore.openNewCredential(
req.credentialType,
false,
false,
props.projectId,
req.suggestedName,
undefined,
undefined,
{
closeOnSave: true,
},
);
}
/** Build a minimal synthetic INodeUi so NodeCredentials can render in standalone mode. */
@@ -11,14 +11,25 @@ import { useNDVStore } from '@/features/ndv/shared/ndv.store';
import { createWorkflowDocumentId } from '@/app/stores/workflowDocument.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useUIStore } from '@/app/stores/ui.store';
import type { NewCredentialsModal } from '@/Interface';
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';
const { confirmMock, routerCurrentRouteMock, routerReplaceMock } = vi.hoisted(() => ({
confirmMock: vi.fn(),
routerCurrentRouteMock: { value: { query: {} } },
routerReplaceMock: vi.fn(),
}));
vi.mock('vue-router', async () => ({
...(await vi.importActual('vue-router')),
useRouter: vi.fn(),
useRouter: vi.fn(() => ({
currentRoute: routerCurrentRouteMock,
replace: routerReplaceMock,
})),
useRoute: () => ({
params: {},
query: {},
@@ -33,8 +44,6 @@ vi.mock('@/app/composables/useToast', () => ({
}),
}));
const { confirmMock } = vi.hoisted(() => ({ confirmMock: vi.fn() }));
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({ confirm: confirmMock }),
}));
@@ -237,14 +246,46 @@ const renderComponent = createComponentRenderer(CredentialEdit, {
},
}),
});
let broadcastMessageListener: ((event: MessageEvent) => void) | undefined;
class BroadcastChannelMock {
constructor(public readonly name: string) {}
addEventListener(_event: 'message', listener: (event: MessageEvent) => void) {
broadcastMessageListener = listener;
}
removeEventListener = vi.fn();
close = vi.fn();
}
const createCredentialResponse = (
overrides: Partial<ICredentialsResponse> = {},
): ICredentialsResponse =>
({
id: 'cred-1',
name: 'Test API account',
type: 'testApi',
isManaged: false,
sharedWithProjects: [],
scopes: ['credential:update'],
...overrides,
}) as ICredentialsResponse;
describe('CredentialEdit', () => {
beforeEach(() => {
broadcastMessageListener = undefined;
routerCurrentRouteMock.value = { query: {} };
const externalSecretsStore = mockedStore(useExternalSecretsStore);
externalSecretsStore.fetchSecretsForProject.mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
test('shows the save button when credentialId is null', async () => {
@@ -782,6 +823,280 @@ describe('CredentialEdit', () => {
);
});
describe('saving credentials', () => {
const createPiniaForSaveTest = (credentialModalState: Partial<NewCredentialsModal> = {}) =>
createTestingPinia({
initialState: {
[STORES.UI]: {
modalsById: {
[CREDENTIAL_EDIT_MODAL_KEY]: {
open: true,
showAuthSelector: false,
...credentialModalState,
},
},
},
[STORES.SETTINGS]: {
settings: {
enterprise: {
sharing: true,
externalSecrets: false,
},
templates: {
host: '',
},
},
},
[STORES.PROJECTS]: {
personalProject: {
id: 'personal-project',
type: 'personal',
scopes: ['credential:create', 'credential:read', 'credential:update'],
},
},
},
});
const setupNewCredential = (
credentialType: ICredentialType,
credentialModalState: Partial<NewCredentialsModal> = {},
) => {
const pinia = createPiniaForSaveTest(credentialModalState);
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.state.credentialTypes = {
[credentialType.name]: credentialType,
};
credentialsStore.getNewCredentialName.mockResolvedValue(
`${credentialType.displayName} account`,
);
credentialsStore.createNewCredential.mockResolvedValue(
createCredentialResponse({
type: credentialType.name,
name: `${credentialType.displayName} account`,
}),
);
const uiStore = mockedStore(useUIStore);
return { credentialsStore, pinia, uiStore };
};
const setupExistingOAuthCredential = (
credentialModalState: Partial<NewCredentialsModal> = {},
) => {
vi.stubGlobal('BroadcastChannel', BroadcastChannelMock);
vi.stubGlobal(
'open',
vi.fn(() => ({ close: vi.fn() })),
);
const pinia = createPiniaForSaveTest(credentialModalState);
const credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.state.credentialTypes = {
[oAuth2Api.name]: oAuth2Api,
};
credentialsStore.state.credentials = {
'oauth-cred': createCredentialResponse({
id: 'oauth-cred',
name: 'OAuth account',
type: oAuth2Api.name,
}),
};
credentialsStore.getCredentialData.mockResolvedValueOnce({
// @ts-expect-error data is decrypted
data: {
grantType: 'authorizationCode',
authUrl: 'https://auth.example.com',
accessTokenUrl: 'https://token.example.com',
clientId: 'client',
clientSecret: 'secret',
},
createdAt: '2026-05-22T10:00:00.000Z',
updatedAt: '2026-05-22T10:00:00.000Z',
id: 'oauth-cred',
name: 'OAuth account',
type: oAuth2Api.name,
isManaged: false,
sharedWithProjects: [],
scopes: ['credential:update'],
oauthTokenData: false,
});
credentialsStore.updateCredential.mockResolvedValue(
createCredentialResponse({
id: 'oauth-cred',
name: 'OAuth account',
type: oAuth2Api.name,
}),
);
credentialsStore.oAuth2Authorize.mockResolvedValue('https://example.com/oauth');
credentialsStore.fetchAllCredentials.mockResolvedValue([]);
const uiStore = mockedStore(useUIStore);
const renderResult = renderComponent({
props: {
activeId: 'oauth-cred',
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'edit',
},
pinia,
});
return { credentialsStore, uiStore, ...renderResult };
};
test('closes the modal after saving credentials that cannot be tested when closeOnSave is enabled', async () => {
const credentialType = {
name: 'testApi',
displayName: 'Test API',
properties: [],
} as ICredentialType;
const { credentialsStore, pinia, uiStore } = setupNewCredential(credentialType, {
closeOnSave: true,
});
const { getByTestId } = renderComponent({
props: {
activeId: credentialType.name,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'new',
},
pinia,
});
await waitFor(() => expect(credentialsStore.getNewCredentialName).toHaveBeenCalled());
await userEvent.click(within(getByTestId('credential-save-button')).getByRole('button'));
await waitFor(() => {
expect(credentialsStore.createNewCredential).toHaveBeenCalled();
expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY);
});
expect(credentialsStore.testCredential).not.toHaveBeenCalled();
});
test('keeps the modal open after saving credentials by default', async () => {
const credentialType = {
name: 'testApi',
displayName: 'Test API',
properties: [],
} as ICredentialType;
const { credentialsStore, pinia, uiStore } = setupNewCredential(credentialType);
const { getByTestId } = renderComponent({
props: {
activeId: credentialType.name,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'new',
},
pinia,
});
await waitFor(() => expect(credentialsStore.getNewCredentialName).toHaveBeenCalled());
await userEvent.click(within(getByTestId('credential-save-button')).getByRole('button'));
await waitFor(() => expect(credentialsStore.createNewCredential).toHaveBeenCalled());
expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(credentialsStore.testCredential).not.toHaveBeenCalled();
});
test('closes the modal after saving credentials with a successful connection test when closeOnSave is enabled', async () => {
const credentialType = {
name: 'testApi',
displayName: 'Test API',
properties: [],
test: { request: {} },
} as unknown as ICredentialType;
const { credentialsStore, pinia, uiStore } = setupNewCredential(credentialType, {
closeOnSave: true,
});
credentialsStore.testCredential.mockResolvedValue({
status: 'OK',
message: 'Connected',
});
const { getByTestId } = renderComponent({
props: {
activeId: credentialType.name,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'new',
},
pinia,
});
await waitFor(() => expect(credentialsStore.getNewCredentialName).toHaveBeenCalled());
await userEvent.click(within(getByTestId('credential-save-button')).getByRole('button'));
await waitFor(() => {
expect(credentialsStore.testCredential).toHaveBeenCalled();
expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY);
});
});
test('keeps the modal open when the connection test fails after saving', async () => {
const credentialType = {
name: 'testApi',
displayName: 'Test API',
properties: [],
test: { request: {} },
} as unknown as ICredentialType;
const { credentialsStore, pinia, uiStore } = setupNewCredential(credentialType, {
closeOnSave: true,
});
credentialsStore.testCredential.mockResolvedValue({
status: 'Error',
message: 'Could not connect',
});
const { getByTestId } = renderComponent({
props: {
activeId: credentialType.name,
modalName: CREDENTIAL_EDIT_MODAL_KEY,
mode: 'new',
},
pinia,
});
await waitFor(() => expect(credentialsStore.getNewCredentialName).toHaveBeenCalled());
await userEvent.click(within(getByTestId('credential-save-button')).getByRole('button'));
await waitFor(() => expect(credentialsStore.testCredential).toHaveBeenCalled());
expect(uiStore.closeModal).not.toHaveBeenCalled();
});
test('closes the modal only after a successful OAuth callback when closeOnSave is enabled', async () => {
const { credentialsStore, uiStore, getByTestId } = setupExistingOAuthCredential({
closeOnSave: true,
});
await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled());
await waitFor(() => expect(getByTestId('quick-connect-button')).toBeVisible());
await userEvent.click(getByTestId('quick-connect-button'));
await waitFor(() => expect(credentialsStore.oAuth2Authorize).toHaveBeenCalled());
expect(uiStore.closeModal).not.toHaveBeenCalled();
broadcastMessageListener?.({ data: 'success' } as MessageEvent);
await waitFor(() =>
expect(uiStore.closeModal).toHaveBeenCalledWith(CREDENTIAL_EDIT_MODAL_KEY),
);
});
test('keeps the modal open after a successful OAuth callback by default', async () => {
const { credentialsStore, uiStore, getByTestId } = setupExistingOAuthCredential();
await waitFor(() => expect(credentialsStore.getCredentialData).toHaveBeenCalled());
await waitFor(() => expect(getByTestId('quick-connect-button')).toBeVisible());
await userEvent.click(getByTestId('quick-connect-button'));
await waitFor(() => expect(credentialsStore.oAuth2Authorize).toHaveBeenCalled());
broadcastMessageListener?.({ data: 'success' } as MessageEvent);
await waitFor(() => expect(credentialsStore.fetchAllCredentials).toHaveBeenCalled());
expect(uiStore.closeModal).not.toHaveBeenCalled();
});
});
describe('per-user OAuth banner', () => {
const createPiniaForBannerTest = () =>
createTestingPinia({
@@ -171,6 +171,11 @@ const hideAskAssistant = computed<boolean>(() => {
return isCredentialModalState(modalState) && modalState.hideAskAssistant === true;
});
const closeOnSave = computed<boolean>(() => {
const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY];
return isCredentialModalState(modalState) && modalState.closeOnSave === true;
});
const activeNodeType = computed(() => {
const activeNode = contextNode.value;
@@ -1022,9 +1027,17 @@ async function saveCredential(): Promise<ICredentialsResponse | null> {
await testCredential(credentialDetails);
isTesting.value = false;
if (testedSuccessfully.value && closeOnSave.value) {
closeDialog();
}
} else {
authError.value = '';
testedSuccessfully.value = false;
if (!isOAuthType.value && closeOnSave.value) {
closeDialog();
}
}
const trackProperties: ITelemetryTrackProperties = {
@@ -1381,6 +1394,10 @@ async function oAuthCredentialAuthorize() {
if (oauthPopup) {
oauthPopup.close();
}
if (closeOnSave.value) {
closeDialog();
}
}
};
oauthChannel.addEventListener('message', receiveMessage);
@@ -117,6 +117,9 @@ const createNewCredential = () => {
false,
props.projectId,
props.suggestedCredentialName,
undefined,
undefined,
{ closeOnSave: true },
);
wasModalOpenedFromHere.value = true;
emit('credentialModalOpened', undefined);
@@ -311,7 +311,7 @@ describe('NodeCredentials', () => {
undefined,
httpNode.name,
httpNode,
{ hideAskAssistant: false },
{ hideAskAssistant: false, closeOnSave: true },
);
});
@@ -844,7 +844,7 @@ describe('NodeCredentials', () => {
name: slackNode.name,
type: slackNode.type,
}),
{ hideAskAssistant: false },
{ hideAskAssistant: false, closeOnSave: true },
);
});
@@ -426,7 +426,7 @@ function createNewCredential(
props.suggestedCredentialName,
props.node.name,
props.node,
{ hideAskAssistant: props.hideAskAssistant },
{ hideAskAssistant: props.hideAskAssistant, closeOnSave: true },
);
telemetry.track('User opened Credential modal', {
credential_type: credentialType,
@@ -92,7 +92,8 @@ export class CredentialModal extends BaseModal {
/**
* Wait for save to fully complete.
* After saving (and optional credential testing), the button either shows a
* "Saved" label or settles back to a disabled "Save" state.
* "Saved" label, settles back to a disabled "Save" state, or the credential
* modal closes.
*/
async waitForSaveComplete(): Promise<void> {
const saveCompleted = this.root.getByText('Saved', { exact: true }).or(
@@ -101,7 +102,10 @@ export class CredentialModal extends BaseModal {
.filter({ hasText: /^Save$/ }),
);
await expect(saveCompleted).toBeVisible({ timeout: 20_000 });
await Promise.any([
saveCompleted.waitFor({ state: 'visible', timeout: 20_000 }),
this.root.waitFor({ state: 'hidden', timeout: 20_000 }),
]);
}
async save(): Promise<void> {