mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
fix(editor): Close credential modal after successful setup (#31925)
Co-authored-by: Ricardo Espinoza <[email protected]>
This commit is contained in:
co-authored by
Ricardo Espinoza
parent
06df815c4b
commit
7b2c722182
@@ -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,
|
||||
|
||||
+10
-1
@@ -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', () => {
|
||||
|
||||
+12
-1
@@ -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. */
|
||||
|
||||
+318
-3
@@ -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({
|
||||
|
||||
+17
@@ -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);
|
||||
|
||||
+3
@@ -117,6 +117,9 @@ const createNewCredential = () => {
|
||||
false,
|
||||
props.projectId,
|
||||
props.suggestedCredentialName,
|
||||
undefined,
|
||||
undefined,
|
||||
{ closeOnSave: true },
|
||||
);
|
||||
wasModalOpenedFromHere.value = true;
|
||||
emit('credentialModalOpened', undefined);
|
||||
|
||||
+2
-2
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user