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;
}
}