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 c5cbcc3be75..685f554e7f3 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 @@ -247,6 +247,17 @@ const renderComponent = createComponentRenderer(CredentialEdit, { }), }); +const modalLoadingStub = { + props: ['loading'], + template: ` +
+ + + +
+ `, +}; + let broadcastMessageListener: ((event: MessageEvent) => void) | undefined; class BroadcastChannelMock { @@ -498,12 +509,50 @@ describe('CredentialEdit', () => { const { getByTestId } = renderComponent({ props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }, + global: { + stubs: { + Modal: modalLoadingStub, + }, + }, }); await waitFor(() => { + expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute('data-loading', 'false'); expect(getByTestId('credential-edit-dialog')).toBeInTheDocument(); }); }); + + it('should stop loading when credential loading fails', async () => { + const credentialsStore = mockedStore(useCredentialsStore); + credentialsStore.getCredentialData.mockRejectedValueOnce(new Error('Failed to load')); + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { getByTestId } = renderComponent({ + props: { + activeId: 'missing-credential', + modalName: CREDENTIAL_EDIT_MODAL_KEY, + mode: 'edit', + }, + global: { + stubs: { + Modal: modalLoadingStub, + }, + }, + }); + + try { + await waitFor(() => { + expect(credentialsStore.getCredentialData).toHaveBeenCalled(); + expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute( + 'data-loading', + 'false', + ); + }); + } finally { + consoleErrorSpy.mockRestore(); + } + }); }); describe('managed credential scope hiding', () => { @@ -711,6 +760,51 @@ describe('CredentialEdit', () => { await retry(() => expect(queryByText('Custom Scopes')).toBeInTheDocument()); expect(queryByText('Enabled Scopes')).toBeInTheDocument(); }); + + it('should not block modal when external hooks throw', async () => { + window.n8nExternalHooks = { + credentialsEdit: { + credentialModalOpened: [ + () => { + throw new Error('plugin error'); + }, + ], + }, + }; + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { getByTestId } = renderComponent({ + props: { modalName: CREDENTIAL_EDIT_MODAL_KEY, mode: 'new' }, + global: { + stubs: { + Modal: modalLoadingStub, + }, + }, + }); + + try { + // Wait for the modal to appear and loading to finish + await waitFor(() => { + expect(getByTestId('credential-edit-modal-stub')).toHaveAttribute( + 'data-loading', + 'false', + ); + expect(getByTestId('credential-edit-dialog')).toBeInTheDocument(); + }); + + // The hook error is logged asynchronously; wait for it + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalledWith( + '[CredentialEdit] External hooks execution failed', + expect.any(Error), + ); + }); + } finally { + consoleErrorSpy.mockRestore(); + delete window.n8nExternalHooks; + } + }); }); test('should use the requested credential type when node has multiple credential types', async () => { 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 146cff9dc5c..73a9a870671 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 @@ -491,89 +491,101 @@ watch( ); onMounted(async () => { - const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]; - requiredCredentials.value = - isCredentialModalState(modalState) && modalState.showAuthSelector === true; + // Inner try isolates optional secrets loading; outer try catches all other initialization failures. + try { + const modalState = uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY]; + requiredCredentials.value = + isCredentialModalState(modalState) && modalState.showAuthSelector === true; - const forceManual = isCredentialModalState(modalState) && modalState.forceManualMode === true; + const forceManual = isCredentialModalState(modalState) && modalState.forceManualMode === true; - const overrideProjectId = isCredentialModalState(modalState) ? modalState.projectId : undefined; - const projectId = - overrideProjectId ?? projectsStore.currentProjectId ?? projectsStore.personalProject?.id; - if (projectId) { - try { - await externalSecretsStore.fetchSecretsForProject(projectId); - } catch { - // Secrets fetch failure should not block the credential modal - } - } - - if (props.mode === 'new' && credentialTypeName.value) { - const modalSuggestedName = isCredentialModalState(modalState) - ? modalState.suggestedName - : undefined; - credentialName.value = modalSuggestedName - ? modalSuggestedName - : await credentialsStore.getNewCredentialName({ - credentialTypeName: defaultCredentialTypeName.value, - }); - - credentialData.value = { - ...credentialData.value, - ...(homeProject.value ? { homeProject: homeProject.value } : {}), - }; - } else { - await loadCurrentCredential(); - } - - setCredentialPropertyDefaults(); - - // Detect if existing credential uses custom OAuth (user-provided clientId/clientSecret). - // Use __overwrittenProperties directly instead of managedOAuthAvailable so that skip-list - // types (where managedOAuthAvailable is false) still auto-detect custom credentials. - if ( - credentialType.value?.__overwrittenProperties?.includes('clientId') && - credentialData.value.clientId && - credentialData.value.clientSecret - ) { - useCustomOAuth.value = true; - } - - // Default to quick connect mode for new credentials when available and not forced to manual - if ( - props.mode === 'new' && - !forceManual && - credentialTypeName.value && - ndvStore.value.activeNode - ) { - const qcOption = getQuickConnectOption( - credentialTypeName.value, - ndvStore.value.activeNode.type, - ); - if (qcOption) { - isQuickConnectMode.value = true; - } - } - - await externalHooks.run('credentialsEdit.credentialModalOpened', { - credentialType: credentialTypeName.value, - isEditingCredential: props.mode === 'edit', - activeNode: ndvStore.value.activeNode, - }); - - setTimeout(async () => { - if (credentialId.value) { - if (!requiredPropertiesFilled.value && credentialPermissions.value.update) { - // sharees can't see properties, so this check would always fail for them - // if the credential contains required fields. - showValidationWarning.value = true; - } else { - await retestCredential(); + const overrideProjectId = isCredentialModalState(modalState) ? modalState.projectId : undefined; + const projectId = + overrideProjectId ?? projectsStore.currentProjectId ?? projectsStore.personalProject?.id; + if (projectId) { + try { + await externalSecretsStore.fetchSecretsForProject(projectId); + } catch { + // Secrets fetch failure should not block the credential modal } } - }, 0); - loading.value = false; + if (props.mode === 'new' && credentialTypeName.value) { + const modalSuggestedName = isCredentialModalState(modalState) + ? modalState.suggestedName + : undefined; + credentialName.value = modalSuggestedName + ? modalSuggestedName + : await credentialsStore.getNewCredentialName({ + credentialTypeName: defaultCredentialTypeName.value, + }); + + credentialData.value = { + ...credentialData.value, + ...(homeProject.value ? { homeProject: homeProject.value } : {}), + }; + } else { + // loadCurrentCredential handles its own recovery by showing an error toast and closing the modal. + // It should be allowed to propagate to avoid subsequent initialization steps running on empty data. + await loadCurrentCredential(); + } + + setCredentialPropertyDefaults(); + + // Detect if existing credential uses custom OAuth (user-provided clientId/clientSecret). + // Use __overwrittenProperties directly instead of managedOAuthAvailable so that skip-list + // types (where managedOAuthAvailable is false) still auto-detect custom credentials. + if ( + credentialType.value?.__overwrittenProperties?.includes('clientId') && + credentialData.value.clientId && + credentialData.value.clientSecret + ) { + useCustomOAuth.value = true; + } + + // Default to quick connect mode for new credentials when available and not forced to manual + if ( + props.mode === 'new' && + !forceManual && + credentialTypeName.value && + ndvStore.value.activeNode + ) { + const qcOption = getQuickConnectOption( + credentialTypeName.value, + ndvStore.value.activeNode.type, + ); + if (qcOption) { + isQuickConnectMode.value = true; + } + } + + // External hooks are fire-and-forget so slow or failing hooks cannot keep the modal loading. + void externalHooks + .run('credentialsEdit.credentialModalOpened', { + credentialType: credentialTypeName.value, + isEditingCredential: props.mode === 'edit', + activeNode: ndvStore.value.activeNode, + }) + .catch((error) => { + console.error('[CredentialEdit] External hooks execution failed', error); + }); + + setTimeout(async () => { + if (credentialId.value) { + if (!requiredPropertiesFilled.value && credentialPermissions.value.update) { + // sharees can't see properties, so this check would always fail for them + // if the credential contains required fields. + showValidationWarning.value = true; + } else { + await retestCredential(); + } + } + }, 0); + } catch (error) { + console.error('[CredentialEdit] Initialization error', error); + } finally { + loading.value = false; + } }); async function beforeClose() { @@ -729,8 +741,7 @@ async function loadCurrentCredential(id = props.activeId ?? '') { i18n.baseText('credentialEdit.credentialEdit.showError.loadCredential.title'), ); closeDialog(); - - return; + throw error; } }