fix(editor): Improve n8n Assistant async credential setup (no-changelog) (#39229)

This commit is contained in:
oleg 2026-09-23 11:37:03 +00:00 committed by GitHub
parent dbc822ba8b
commit 1e1f3cb711
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 878 additions and 106 deletions

View File

@ -63,7 +63,7 @@ const { t } = useI18n();
<template #trigger>
<N8nButton
size="small"
variant="subtle"
variant="ghost"
icon-only
:disabled="disabled || loading"
:aria-label="t('setupPanel.moreOptions')"

View File

@ -260,7 +260,7 @@ watch(
.base,
.overlay {
--animation--popover-in--scale: 0.7;
--animation--popover-in--scale: 0.95;
align-self: end;
}
@ -294,7 +294,7 @@ watch(
.enterFrom,
.leaveTo {
opacity: 0;
transform: scale(0.7);
transform: scale(0.95);
}
.listSpacing {

View File

@ -7217,6 +7217,7 @@
"instanceAi.setupPanel.helpFindAccessToken": "Help me find my access token",
"instanceAi.setupPanel.helpSetUp": "Help me set this up",
"instanceAi.setupPanel.helpVisibleFields": "The OAuth form asks for: {fields}.",
"instanceAi.setupPanel.helpOAuthState": "Selected OAuth mode: {mode}. Connection state: {state}.",
"instanceAi.setupPanel.helpManagedOAuthAvailable": "Managed OAuth is available on this instance.",
"instanceAi.setupPanel.helpManagedOAuthUnavailable": "Managed OAuth isn't available on this instance. Help me configure my own OAuth app.",
"instanceAi.setupPanel.setCredentialsPerNode": "Set credentials per node",

View File

@ -31,6 +31,7 @@ import { useExternalHooks } from '@/app/composables/useExternalHooks';
import type { InstanceAiCredentialContext } from '@/app/composables/useInstanceAiEditorCapability';
import { useCredentialForm } from '@/features/credentials/composables/useCredentialForm';
import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth';
import { hasOAuthTokenData } from '@/features/credentials/composables/oauthCallback';
import { useQuickConnect } from '@/features/credentials/quickConnect/composables/useQuickConnect';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import CredentialInputs from '@/features/credentials/components/CredentialEdit/CredentialInputs.vue';
@ -85,6 +86,7 @@ const { openTopUp } = useAiGatewayTopUp();
const initialized = ref(false);
const initializationFailed = ref(false);
const busy = ref(false);
const reopenAuthorization = ref<() => void>();
const createNew = ref(false);
const hasDraft = ref(false);
watch(busy, (value) => emit('update:busy', value));
@ -93,9 +95,11 @@ watch(
(value) => emit('update:hasChanges', value),
);
const chooseExisting = ref(false);
const oauthConnection = ref<boolean>();
const oauthMode = ref<'managed' | 'custom' | 'unknown'>('unknown');
const loadingOAuth = ref(false);
const mode = ref<'credits' | 'own'>('credits');
const modeChanged = ref(false);
const usesCustomOAuth = ref(false);
let active = true;
useSetupPanelDocument({
@ -144,25 +148,9 @@ const connected = computed(
() =>
!createNew.value &&
!hasDraft.value &&
Boolean(binding.value?.id || binding.value?.__aiGatewayManaged),
);
watch(
() => (isOAuth.value && canQuickConnect.value ? binding.value?.id : undefined),
async (id) => {
usesCustomOAuth.value = false;
if (!id) return;
try {
const credential = await credentialsStore.getCredentialData({ id });
if (!active || binding.value?.id !== id) return;
const data = credential?.data;
usesCustomOAuth.value = Boolean(
typeof data === 'object' && data?.clientId && data?.clientSecret,
);
} catch {
// Shared credentials can be usable without permission to read their data.
}
},
{ immediate: true },
Boolean(binding.value?.id || binding.value?.__aiGatewayManaged) &&
!loadingOAuth.value &&
!needsAuthorization.value,
);
const usableCredentials = computed(() =>
credentialsStore.hasUsableCredentialsForScope({ workflowId: props.workflowId })
@ -175,11 +163,63 @@ const storedCredential = computed(() =>
credentialsStore.getCredentialById(binding.value.id))
: undefined,
);
const usesCustomOAuth = computed(() => oauthMode.value === 'custom');
const selectedOAuthId = computed(() =>
!createNew.value && !hasDraft.value && isOAuth.value ? binding.value?.id : undefined,
);
watch(
[
selectedOAuthId,
() => storedCredential.value?.updatedAt,
() => storedCredential.value?.connectedByMe,
],
async ([id], _previous, onCleanup) => {
let stale = false;
onCleanup(() => {
stale = true;
});
oauthConnection.value = undefined;
oauthMode.value = 'unknown';
loadingOAuth.value = Boolean(id);
if (!id) return;
try {
const credential = await credentialsStore.getCredentialData({ id });
if (stale) return;
const data = credential?.data;
if (credential?.isResolvable) oauthConnection.value = credential.connectedByMe;
if (data && typeof data === 'object') {
if (!credential?.isResolvable) {
oauthConnection.value =
Boolean(
data.grantType && !['authorizationCode', 'pkce'].includes(String(data.grantType)),
) || hasOAuthTokenData(credential);
}
// Stored client fields identify a custom app even when managed OAuth is available.
const customClient = Boolean(
(data.clientId && data.clientSecret) || (data.consumerKey && data.consumerSecret),
);
oauthMode.value = customClient || !form.managedOAuthAvailable.value ? 'custom' : 'managed';
}
} catch {
// A shared credential can be usable without permission to read its data.
} finally {
if (!stale) loadingOAuth.value = false;
}
},
{ immediate: true },
);
const needsAuthorization = computed(
() => Boolean(selectedOAuthId.value) && oauthConnection.value === false,
);
const showExistingPicker = computed(
() =>
chooseExisting.value ||
storedCredential.value?.isResolvable ||
(!connected.value && !createNew.value && !hasDraft.value && usableCredentials.value.length > 0),
(!connected.value &&
!needsAuthorization.value &&
!createNew.value &&
!hasDraft.value &&
usableCredentials.value.length > 0),
);
const gatewayAvailable = computed(
() =>
@ -216,6 +256,11 @@ const inlineFields = computed(() => {
property.type !== 'hidden' &&
property.type !== 'notice' &&
!property.typeOptions?.copyButton &&
!(
props.item.credentialType === 'googlePalmApi' &&
property.name === 'host' &&
(form.credentialData.value.host ?? property.default) === property.default
) &&
!DOMAIN_RESTRICTION_FIELDS.some(({ name }) => name === property.name),
);
const required = fields.filter((property) => property.required);
@ -257,24 +302,19 @@ const value = computed(() =>
);
const useCredits = computed(() => gatewayAvailable.value && mode.value === 'credits');
const actionLabel = computed(() =>
useCredits.value
? i18n.baseText('instanceAi.setupPanel.useCredits')
: advancedIsPrimary.value
? i18n.baseText('instanceAi.setupPanel.advancedSetup')
: canQuickConnect.value || useAdvancedForm.value
? i18n.baseText('instanceAi.setupPanel.connect')
: isOAuth.value
? i18n.baseText('instanceAi.setupPanel.saveAndSignIn')
: i18n.baseText('generic.save'),
);
const actionDisabled = computed(
() =>
!initialized.value ||
(!useCredits.value &&
!canQuickConnect.value &&
!useAdvancedForm.value &&
!form.requiredPropertiesFilled.value),
needsAuthorization.value
? i18n.baseText('credentialEdit.oAuthButton.connectMyAccount')
: useCredits.value
? i18n.baseText('instanceAi.setupPanel.useCredits')
: advancedIsPrimary.value
? i18n.baseText('instanceAi.setupPanel.advancedSetup')
: canQuickConnect.value || useAdvancedForm.value
? i18n.baseText('instanceAi.setupPanel.connect')
: isOAuth.value
? i18n.baseText('instanceAi.setupPanel.saveAndSignIn')
: i18n.baseText('generic.save'),
);
const actionDisabled = computed(() => !initialized.value || loadingOAuth.value);
const redirectUrl = computed(() => {
const urls = rootStore.OAuthCallbackUrls;
if (form.parentTypes.value.includes('oAuth1Api') || props.item.credentialType === 'oAuth1Api') {
@ -303,6 +343,11 @@ const actions = computed<DropdownMenuItemProps[]>(() => {
? [{ id: 'existing', label: i18n.baseText('instanceAi.setupPanel.useExisting') }]
: [];
if (!connected.value && useCredits.value) return existing;
if (needsAuthorization.value)
return [
{ id: 'edit', label: i18n.baseText('instanceAi.setupPanel.editCredential') },
...existing,
];
if (connected.value && binding.value?.__aiGatewayManaged)
return [
{ id: 'replace', label: i18n.baseText('instanceAi.setupPanel.useOwnKey') },
@ -342,7 +387,14 @@ const helpLabel = computed(() => {
function askForHelp() {
if (props.helpDisabled || busy.value) return;
const selectedMode = selectedOAuthId.value
? oauthMode.value
: form.isManagedOAuthMode.value
? 'managed'
: 'custom';
const selectedConnection = selectedOAuthId.value ? oauthConnection.value : false;
emit('askForHelp', {
id: binding.value?.id ?? undefined,
credentialType: props.item.credentialType,
displayName: serviceName.value,
nodeName: props.node?.name,
@ -352,7 +404,18 @@ function askForHelp() {
oauthRedirectUrl: isOAuth.value ? redirectUrl.value : undefined,
setupContext: isOAuth.value
? [
fieldTitles.value.length
i18n.baseText('instanceAi.setupPanel.helpOAuthState', {
interpolate: {
mode: selectedMode,
state:
selectedConnection === undefined
? 'unknown'
: selectedConnection
? 'connected'
: 'disconnected',
},
}),
!needsAuthorization.value && fieldTitles.value.length
? i18n.baseText('instanceAi.setupPanel.helpVisibleFields', {
interpolate: { fields: fieldTitles.value.join(', ') },
})
@ -394,7 +457,12 @@ function formData(): ICredentialDataDecryptedObject {
}
function onDataChange(update: IUpdateInformation) {
if (form.onDataChange(update)) hasDraft.value = true;
if (!form.onDataChange(update)) return;
hasDraft.value = true;
if (reopenAuthorization.value) {
reopenAuthorization.value = undefined;
oauth.cancelAuthorize();
}
}
async function saveKey() {
@ -438,11 +506,36 @@ async function saveKey() {
}
async function connect() {
if (reopenAuthorization.value) {
reopenAuthorization.value();
return;
}
if (busy.value || actionDisabled.value) return;
if (
!needsAuthorization.value &&
!useCredits.value &&
!canQuickConnect.value &&
!useAdvancedForm.value
) {
form.showValidationWarning.value = true;
if (!form.requiredPropertiesFilled.value) return;
}
busy.value = true;
form.authError.value = '';
try {
if (useCredits.value) {
if (needsAuthorization.value && storedCredential.value) {
emit('connectStarted', 'oauth');
const credential = await oauth.authorizeExistingCredential(storedCredential.value, {
workflowId: props.workflowId,
onAuthorizationStarted: (reopen) => {
reopenAuthorization.value = reopen;
},
});
if (credential) {
oauthConnection.value = true;
emitBinding(credential.id);
}
} else if (useCredits.value) {
emit('connectStarted', 'gateway');
emitBinding(AI_GATEWAY_MANAGED_TAG);
} else if (useAdvancedForm.value) {
@ -458,6 +551,9 @@ async function connect() {
workflowId: props.workflowId,
data: submittedData,
name: form.credentialName.value || undefined,
onAuthorizationStarted: (reopen) => {
reopenAuthorization.value = reopen;
},
},
);
if (credential) emitBinding(credential.id, submittedData);
@ -479,6 +575,7 @@ async function connect() {
} catch (error) {
toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
} finally {
reopenAuthorization.value = undefined;
busy.value = false;
}
}
@ -551,7 +648,7 @@ async function initialize() {
if (active) toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
}
}
if (connected.value) initialized.value = true;
if (binding.value?.id || binding.value?.__aiGatewayManaged) initialized.value = true;
else void initialize();
watch(
() => props.item.setupHint,
@ -640,13 +737,13 @@ onScopeDispose(() => {
</template>
<N8nSetupConnection
v-else
:connected="connected"
:connected="connected && !reopenAuthorization"
:value-label="valueLabel"
:value="value"
:action-label="actionLabel"
:actions="actions"
:actions="reopenAuthorization ? [] : actions"
:action-disabled="actionDisabled"
:loading="busy"
:loading="loadingOAuth || (busy && !reopenAuthorization)"
@action="connect"
@select="onMenuAction"
>
@ -678,7 +775,16 @@ onScopeDispose(() => {
{{ helpLabel }}
</N8nButton>
</template>
<template v-if="!useCredits && !canQuickConnect && !useAdvancedForm">
<N8nText v-if="needsAuthorization" size="small">{{ value }}</N8nText>
<template
v-if="
!loadingOAuth &&
!needsAuthorization &&
!useCredits &&
!canQuickConnect &&
!useAdvancedForm
"
>
<label v-if="isOAuth && redirectUrl" :class="$style.redirect">
{{ i18n.baseText('instanceAi.setupPanel.redirectUrl') }}
<N8nCopyInput
@ -692,6 +798,7 @@ onScopeDispose(() => {
v-if="isTemplated"
compact
:credential-data="form.credentialData.value"
:show-validation-warnings="form.showValidationWarning.value"
@update="onDataChange"
/>
<CredentialInputs
@ -734,6 +841,7 @@ onScopeDispose(() => {
.help {
padding-inline: 0;
color: var(--text-color--subtle);
}
.form .existing {

View File

@ -19,6 +19,7 @@ import {
type SetupPanelItem,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { NodeHelpers } from 'n8n-workflow';
import { useToast } from '@n8n/composables/useToast';
import type { INodeUi } from '@/Interface';
import {
@ -80,6 +81,7 @@ const usersStore = useUsersStore();
const rootStore = useRootStore();
const oauth = useCredentialOAuth();
const connectingItemId = ref<string>();
const reopenAuthorization = ref<() => void>();
let active = true;
onScopeDispose(() => {
active = false;
@ -328,8 +330,9 @@ const panelItems = computed<SetupPanelItem[]>(() =>
!group.credential.isDone &&
oauth.isOAuthCredentialType(group.credential.item.credentialType) &&
oauth.canOAuthCredentialQuickConnect(group.credential.item.credentialType) &&
credentialsStore.getUsableCredentialByType(group.credential.item.credentialType)
.length === 0,
(connectingItemId.value === group.id ||
credentialsStore.getUsableCredentialByType(group.credential.item.credentialType)
.length === 0),
),
completed:
(!group.credential || group.credential.isDone) &&
@ -394,7 +397,19 @@ const detailSections = computed(() => {
const group = selectedGroup.value;
if (!group) return [];
const credential = group.credential?.item;
const editors = parameterEditors.value;
const editors = parameterEditors.value.filter(({ item, node }) => {
const type = nodeType(node);
if (!type) return false;
const values =
NodeHelpers.getNodeParameters(type.properties, node.parameters, true, true, node, type) ??
node.parameters;
return type.properties.some(
(property) =>
property.type !== 'hidden' &&
item.parameterNames.some((name) => name.split(/[.[\]]/)[0] === property.name) &&
NodeHelpers.displayParameter(values, property, node, type),
);
});
const nodes = credential ? selectedNodes.value : editors.map((editor) => editor.node);
return [
...(credential && !perNodeCredentials.value
@ -432,7 +447,9 @@ const detailSections = computed(() => {
? isCredentialConfigured(node.credentials?.[credential.credentialType])
: !group.credential || group.credential.isDone,
})),
];
].filter(
(section) => section.credential || (section.showParameters && section.editors.length > 0),
);
});
const parameterEditorComponents = useTemplateRef<
Array<InstanceType<typeof InstanceAiSetupPanelDetail>>
@ -599,6 +616,10 @@ async function onExecute() {
}
async function connectFromRow(id: string) {
if (connectingItemId.value === id && reopenAuthorization.value) {
reopenAuthorization.value();
return;
}
const group = groupById(id);
const item = group?.credential?.item;
const projectId = credentialProjectId.value;
@ -622,11 +643,15 @@ async function connectFromRow(id: string) {
const credential = await oauth.createAndAuthorize(item.credentialType, node?.type, {
projectId,
workflowId,
onAuthorizationStarted: (reopen) => {
reopenAuthorization.value = reopen;
},
});
if (credential) await bind(credential.id);
} catch (error) {
if (active) toast.showError(error, i18n.baseText('instanceAi.setupPanel.connectionError'));
} finally {
reopenAuthorization.value = undefined;
connectingItemId.value = undefined;
}
}
@ -708,8 +733,11 @@ async function onConfirmParameters() {
<N8nButton
size="small"
variant="subtle"
:disabled="item.disabled || Boolean(connectingItemId)"
:loading="connectingItemId === item.id"
:disabled="
item.disabled ||
Boolean(connectingItemId && (connectingItemId !== item.id || !reopenAuthorization))
"
:loading="connectingItemId === item.id && !reopenAuthorization"
@click="connectFromRow(item.id)"
>
{{ i18n.baseText('instanceAi.setupPanel.connect') }}

View File

@ -1,6 +1,6 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { computed, defineComponent, h, ref, type PropType } from 'vue';
import { computed, defineComponent, h, reactive, ref, type PropType } from 'vue';
import { flushPromises } from '@vue/test-utils';
import { fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
isOAuth: vi.fn(),
canQuickConnect: vi.fn(),
authorize: vi.fn(),
authorizeExisting: vi.fn(),
cancelAuthorize: vi.fn(),
quickOption: vi.fn(),
quickConnect: vi.fn(),
@ -49,6 +50,7 @@ vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({
isOAuthCredentialType: mocks.isOAuth,
canOAuthCredentialQuickConnect: mocks.canQuickConnect,
createAndAuthorize: mocks.authorize,
authorizeExistingCredential: mocks.authorizeExisting,
cancelAuthorize: mocks.cancelAuthorize,
}),
}));
@ -92,6 +94,8 @@ function createForm() {
isOAuthType: computed(
() => mocks.isOAuth() && credentialData.value.grantType !== 'clientCredentials',
),
managedOAuthAvailable: computed(() => mocks.canQuickConnect()),
isManagedOAuthMode: computed(() => mocks.isOAuth() && mocks.canQuickConnect()),
setCredentialPropertyDefaults: vi.fn(),
requiredPropertiesFilled: computed(() => Boolean(credentialData.value.apiKey)),
showValidationWarning: ref(false),
@ -304,7 +308,7 @@ describe('InstanceAiSetupCredential', () => {
await fireEvent.click(view.getByRole('button', { name: 'Help me set this up' }));
expect(view.emitted<[unknown]>('askForHelp')?.[0]?.[0]).toMatchObject({
setupContext:
"The OAuth form asks for: Client ID, Client Secret. Managed OAuth isn't available on this instance. Help me configure my own OAuth app.",
"Selected OAuth mode: custom. Connection state: disconnected. The OAuth form asks for: Client ID, Client Secret. Managed OAuth isn't available on this instance. Help me configure my own OAuth app.",
});
});
@ -373,6 +377,142 @@ describe('InstanceAiSetupCredential', () => {
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
});
it.each([false, true])(
'validates on submission without starting a connection, OAuth: %s',
async (isOAuth) => {
mocks.isOAuth.mockReturnValue(isOAuth);
const view = renderComponent({ global: { stubs: { CredentialInputs: false } } });
await flushPromises();
await fireEvent.blur(view.getByLabelText('API key'));
expect(view.queryByRole('alert')).toBeNull();
await fireEvent.click(
view.getByRole('button', { name: isOAuth ? 'Save and sign in' : 'Save' }),
);
expect(view.getByRole('alert')).toHaveTextContent('This field is required');
expect(mockedStore(useCredentialsStore).createNewCredential).not.toHaveBeenCalled();
expect(mocks.authorize).not.toHaveBeenCalled();
},
);
it.each(['https://generativelanguage.googleapis.com', 'https://gemini.example.test'])(
'preserves Gemini Host while hiding only its default: %s',
async (host) => {
const defaultHost = 'https://generativelanguage.googleapis.com';
form.credentialData.value = { apiKey: 'test-key', host };
form.credentialProperties.value = [
{ name: 'host', displayName: 'Host', type: 'string', default: defaultHost, required: true },
{ name: 'apiKey', displayName: 'API key', type: 'string', default: '', required: true },
];
mockedStore(useCredentialsStore).createNewCredential.mockResolvedValue(savedCredential);
const view = renderComponent({
props: {
item: { ...item, nodeBindings: [...item.nodeBindings], credentialType: 'googlePalmApi' },
},
});
await flushPromises();
if (host === defaultHost) expect(view.getByTestId('fields')).not.toHaveTextContent('Host');
else expect(view.getByTestId('fields')).toHaveTextContent('Host');
await fireEvent.click(view.getByRole('button', { name: 'Save' }));
await flushPromises();
expect(mockedStore(useCredentialsStore).createNewCredential).toHaveBeenCalledWith(
expect.objectContaining({ data: { apiKey: 'test-key', host } }),
'workflow-project',
undefined,
{ skipStoreUpdate: true },
);
},
);
it('keeps the same sign-in button available without creating another credential', async () => {
mocks.isOAuth.mockReturnValue(true);
form.credentialProperties.value = [
{ name: 'clientId', displayName: 'Client ID', type: 'string', default: '', required: true },
{
name: 'clientSecret',
displayName: 'Client Secret',
type: 'string',
default: '',
required: true,
},
];
const credential = reactive({ ...savedCredential });
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue(credential);
mockedStore(useCredentialsStore).getCredentialData.mockResolvedValue({
...credential,
data: { clientId: 'client', clientSecret: '__n8n_BLANK_VALUE' },
});
const authorization = Promise.withResolvers<ICredentialsResponse | null>();
const reopen = vi.fn();
mocks.authorizeExisting.mockImplementationOnce((_credential, options) => {
options.onAuthorizationStarted(reopen);
return authorization.promise;
});
const view = renderComponent({
props: {
node: {
...node,
credentials: { serviceApi: { id: credential.id, name: credential.name } },
},
},
});
await flushPromises();
expect(view.queryByText('Credential selected')).toBeNull();
await fireEvent.click(view.getByRole('button', { name: 'Help me set this up' }));
expect(view.emitted('askForHelp')?.[0]).toEqual([
expect.objectContaining({
id: credential.id,
setupContext: expect.stringContaining(
'Selected OAuth mode: custom. Connection state: disconnected.',
),
}),
]);
await fireEvent.click(view.getByRole('button', { name: 'Connect my account' }));
expect(view.getByRole('button', { name: 'Connect my account' })).toBeEnabled();
expect(view.queryByRole('button', { name: 'Reopen sign-in' })).toBeNull();
expect(view.queryByRole('button', { name: 'Cancel' })).toBeNull();
await fireEvent.click(view.getByRole('button', { name: 'Connect my account' }));
expect(reopen).toHaveBeenCalledOnce();
expect(mocks.authorizeExisting).toHaveBeenCalledOnce();
authorization.resolve(null);
await flushPromises();
expect(view.getByRole('button', { name: 'Connect my account' })).toBeEnabled();
expect(mocks.authorizeExisting).toHaveBeenCalledWith(
credential,
expect.objectContaining({ workflowId: 'wf' }),
);
expect(mocks.authorize).not.toHaveBeenCalled();
expect(view.emitted('bindCredential')).toBeUndefined();
});
it('uses edited fields for the next attempt instead of reopening the old sign-in', async () => {
mocks.isOAuth.mockReturnValue(true);
const authorization = Promise.withResolvers<ICredentialsResponse | null>();
const reopen = vi.fn();
mocks.authorize
.mockImplementationOnce((_type, _node, options) => {
options.onAuthorizationStarted(reopen);
return authorization.promise;
})
.mockResolvedValueOnce(null);
const view = renderComponent();
await flushPromises();
await fireEvent.update(view.getByLabelText('API key'), 'first');
await fireEvent.click(view.getByRole('button', { name: 'Save and sign in' }));
expect(view.getByRole('button', { name: 'Save and sign in' })).toBeEnabled();
await fireEvent.update(view.getByLabelText('API key'), 'updated');
expect(mocks.cancelAuthorize).toHaveBeenCalledOnce();
authorization.resolve(null);
await flushPromises();
await fireEvent.click(view.getByRole('button', { name: 'Save and sign in' }));
await flushPromises();
expect(reopen).not.toHaveBeenCalled();
expect(mocks.authorize).toHaveBeenLastCalledWith(
'serviceApi',
node.type,
expect.objectContaining({ data: { apiKey: 'updated' } }),
);
});
it('shows required fields and tests the key before publishing and binding it in the workflow project', async () => {
const rendered = renderComponent();
const store = mockedStore(useCredentialsStore);
@ -383,7 +523,7 @@ describe('InstanceAiSetupCredential', () => {
expect(rendered.getByTestId('fields')).toHaveTextContent('apiKey');
expect(rendered.getByTestId('fields')).not.toHaveTextContent('optional');
expect(rendered.getByTestId('fields')).not.toHaveTextContent('hidden');
expect(rendered.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(rendered.getByRole('button', { name: 'Save' })).toBeEnabled();
await fireEvent.update(rendered.getByLabelText('API key'), 'submitted-key');
await fireEvent.click(rendered.getByRole('button', { name: 'Save' }));
await flushPromises();
@ -489,7 +629,12 @@ describe('InstanceAiSetupCredential', () => {
it('reuses managed OAuth and leaves the current account unchanged on cancellation', async () => {
mocks.isOAuth.mockReturnValue(true);
mocks.canQuickConnect.mockReturnValue(true);
mocks.authorize.mockResolvedValue(null);
const authorization = Promise.withResolvers<ICredentialsResponse | null>();
const reopen = vi.fn();
mocks.authorize.mockImplementationOnce((_type, _node, options) => {
options.onAuthorizationStarted(reopen);
return authorization.promise;
});
const rendered = renderComponent({
props: {
node: { ...node, credentials: { serviceApi: { id: 'existing', name: 'Current account' } } },
@ -503,6 +648,11 @@ describe('InstanceAiSetupCredential', () => {
'test.service',
expect.objectContaining({ workflowId: 'wf', projectId: 'workflow-project' }),
);
await fireEvent.click(rendered.getByRole('button', { name: 'Connect' }));
expect(reopen).toHaveBeenCalledOnce();
expect(mocks.authorize).toHaveBeenCalledOnce();
authorization.resolve(null);
await flushPromises();
expect(rendered.getByText('Current account')).toBeVisible();
expect(rendered.emitted<[unknown, string]>('bindCredential')).toBeUndefined();
});
@ -511,9 +661,13 @@ describe('InstanceAiSetupCredential', () => {
mocks.isOAuth.mockReturnValue(true);
mocks.canQuickConnect.mockReturnValue(true);
mocks.authorize.mockResolvedValue(null);
mockedStore(useCredentialsStore).getCredentialById = vi.fn().mockReturnValue({
...savedCredential,
name: 'Custom account',
});
mockedStore(useCredentialsStore).getCredentialData.mockResolvedValue({
...savedCredential,
data: { clientId: 'custom-client', clientSecret: 'custom-secret' },
data: { clientId: 'client', clientSecret: '__n8n_BLANK_VALUE', oauthTokenData: true },
});
const rendered = renderComponent({
props: {
@ -569,6 +723,7 @@ describe('InstanceAiSetupCredential', () => {
projectId: 'workflow-project',
data: { apiKey: 'client-secret' },
name: 'Service account',
onAuthorizationStarted: expect.any(Function),
});
});
@ -776,6 +931,7 @@ describe('InstanceAiSetupCredential', () => {
await rendered.rerender({
node: { ...node, credentials: { serviceApi: { id: 'existing', name: 'Existing account' } } },
});
await flushPromises();
expect(rendered.queryByLabelText('API key')).toBeNull();
expect(rendered.getByText('Existing account')).toBeVisible();
expect(mocks.authorize).not.toHaveBeenCalled();
@ -813,7 +969,7 @@ describe('InstanceAiSetupCredential', () => {
await openMenu(rendered, 'Use my API key');
await flushPromises();
expect(rendered.getByLabelText('API key')).toBeVisible();
expect(rendered.getByRole('button', { name: 'Save' })).toBeDisabled();
expect(rendered.getByRole('button', { name: 'Save' })).toBeEnabled();
});
it.each([

View File

@ -87,8 +87,16 @@ let thread: SetupPanelThreadSource &
vi.mock('../../../instanceAi.store', () => ({ useThread: () => thread }));
const accounts = [
mock<ICredentialsResponse>({ id: 'cred-1', name: 'First account', type: 'slackApi' }),
mock<ICredentialsResponse>({ id: 'cred-2', name: 'Second account', type: 'slackApi' }),
mock<ICredentialsResponse>({
id: 'cred-1',
name: 'First account',
type: 'slackApi',
}),
mock<ICredentialsResponse>({
id: 'cred-2',
name: 'Second account',
type: 'slackApi',
}),
];
const NodeCredentialsStub = defineComponent({
@ -355,6 +363,26 @@ describe('InstanceAiSetupPanel interactions', () => {
return rendered;
}
it('hides empty node sections until the shared credential is selected', async () => {
saved.nodes.push({
...deepCopy(saved.nodes[0]),
id: 'second-node',
name: 'Second notification',
});
const view = renderPanel();
await fireEvent.click(await view.findByRole('button', { name: /Slack/ }));
expect(view.queryByRole('heading', { name: 'Notify' })).toBeNull();
expect(view.queryByRole('heading', { name: 'Second notification' })).toBeNull();
expect(view.queryByLabelText('Channel')).toBeNull();
await fireEvent.update(view.getByLabelText('Account'), 'cred-1');
await flushPromises();
expect(view.getByRole('heading', { name: 'Notify' })).toBeVisible();
expect(view.getByRole('heading', { name: 'Second notification' })).toBeVisible();
const channels = view.getAllByLabelText('Channel');
expect(channels).toHaveLength(2);
for (const channel of channels) expect(channel).toBeVisible();
});
it('preserves drafts when splitting a shared account and saves both nodes with one Confirm', async () => {
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
saved.nodes.push({
@ -670,6 +698,35 @@ describe('InstanceAiSetupPanel interactions', () => {
expect(updateWorkflow).not.toHaveBeenCalled();
});
it('removes a node heading when its last setup field becomes hidden', async () => {
const type = mockedStore(useNodeTypesStore).allNodeTypes[0];
type.properties.unshift({
name: 'resource',
displayName: 'Resource',
type: 'string',
default: 'message',
});
type.properties.find((property) => property.name === 'channel')!.displayOptions = {
show: { resource: ['message'] },
};
saved.nodes[0].parameters = { resource: 'message', channel: '' };
saved.nodes[0].credentials = { slackApi: { id: 'cred-1', name: 'First account' } };
saved.nodes.push({
...deepCopy(saved.nodes[0]),
id: 'second-node',
name: 'Second notification',
});
const view = await openParameters();
expect(view.getByRole('heading', { name: 'Notify' })).toBeVisible();
documentStore.updateNodeProperties({
name: 'Notify',
properties: { parameters: { resource: 'other', channel: '' } },
});
await flushPromises();
expect(view.queryByRole('heading', { name: 'Notify' })).toBeNull();
expect(view.getByRole('heading', { name: 'Second notification' })).toBeVisible();
});
it('renders fields gated by defaults omitted from the saved workflow', async () => {
const type = mockedStore(useNodeTypesStore).allNodeTypes[0];
type.properties.unshift({

View File

@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
import { flushPromises } from '@vue/test-utils';
import type { InstanceAiMessage, InstanceAiSetupItem } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import { mockNodeTypeDescription } from '@/__tests__/mocks';
import type { INodeUi } from '@/Interface';
import type { SetupPanelRow } from '../../../composables/useSetupPanelState';
import InstanceAiSetupPanel from '../InstanceAiSetupPanel.vue';
@ -146,7 +147,14 @@ vi.mock('@/features/credentials/credentials.store', () => ({
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({ getNodeType: () => null }),
useNodeTypesStore: () => ({
getNodeType: (name: string) =>
name === 'n8n-nodes-base.slack'
? mockNodeTypeDescription({
properties: [{ name: 'channel', displayName: 'Channel', type: 'string', default: '' }],
})
: null,
}),
}));
const credentialItem: InstanceAiSetupItem = {
@ -567,6 +575,30 @@ describe('InstanceAiSetupPanel', () => {
expect(getByTestId('setup-panel-back')).toBeVisible();
});
it('keeps Connect available during a row connection without starting another attempt', async () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
stateMock.rows = [{ item: credentialItem, isDone: false }];
const authorization = Promise.withResolvers<null>();
const reopen = vi.fn();
oauthMock.createAndAuthorize.mockImplementationOnce((_type, _node, options) => {
options.onAuthorizationStarted(reopen);
return authorization.promise;
});
const view = renderComponent();
await userEvent.click(view.getByRole('button', { name: 'Connect' }));
expect(view.getByRole('button', { name: 'Connect' })).toBeEnabled();
expect(view.queryByRole('button', { name: 'Reopen sign-in' })).toBeNull();
expect(view.queryByRole('button', { name: 'Cancel' })).toBeNull();
await userEvent.click(view.getByRole('button', { name: 'Connect' }));
expect(reopen).toHaveBeenCalledOnce();
expect(oauthMock.createAndAuthorize).toHaveBeenCalledOnce();
authorization.resolve(null);
await flushPromises();
expect(view.getByRole('button', { name: 'Connect' })).toBeEnabled();
expect(actionsMock.bindCredential).not.toHaveBeenCalled();
});
it('connects managed OAuth directly from the row and keeps cancellation pending', async () => {
oauthMock.isOAuthCredentialType.mockReturnValue(true);
oauthMock.canOAuthCredentialQuickConnect.mockReturnValue(true);
@ -581,6 +613,7 @@ describe('InstanceAiSetupPanel', () => {
expect(oauthMock.createAndAuthorize).toHaveBeenCalledWith('notionApi', undefined, {
projectId: 'p1',
workflowId: 'wf1',
onAuthorizationStarted: expect.any(Function),
});
expect(actionsMock.bindCredential).not.toHaveBeenCalled();
expect(queryByRole('dialog')).toBeNull();

View File

@ -10,7 +10,7 @@ const renderComponent = createComponentRenderer(CredentialInputs);
describe('compact CredentialInputs', () => {
beforeEach(() => setActivePinia(createTestingPinia()));
it('preserves multiline private keys and validates required fields only after blur', async () => {
it('preserves multiline private keys and validates required fields only after submission', async () => {
const rendered = renderComponent({
props: {
compact: true,
@ -33,6 +33,8 @@ describe('compact CredentialInputs', () => {
expect(input).toHaveAttribute('rows', '4');
expect(rendered.queryByRole('alert')).toBeNull();
await fireEvent.blur(input);
expect(rendered.queryByRole('alert')).toBeNull();
await rendered.rerender({ showValidationWarnings: true });
expect(rendered.getByRole('alert')).toHaveTextContent('This field is required');
const key = '-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----';
await fireEvent.update(input, key);

View File

@ -8,7 +8,7 @@ import type { IUpdateInformation } from '@/Interface';
import CopyInput from '@/app/components/CopyInput.vue';
import ParameterInputExpanded from '@/features/ndv/parameters/components/ParameterInputExpanded.vue';
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
import { computed, reactive, useId } from 'vue';
import { computed, useId } from 'vue';
import { N8nInput, N8nInputLabel, N8nNotice, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
@ -24,12 +24,9 @@ type Props = {
const props = defineProps<Props>();
const i18n = useI18n();
const inputId = useId();
const touched = reactive(new Set<string>());
function hasRequiredError(parameter: INodeProperties) {
return (
parameter.required &&
(touched.has(parameter.name) || props.showValidationWarnings) &&
!props.credentialData[parameter.name]
parameter.required && props.showValidationWarnings && !props.credentialData[parameter.name]
);
}
@ -112,7 +109,6 @@ function valueChanged(parameterData: IUpdateInformation) {
:required="parameter.required"
:autocomplete="parameter.typeOptions?.password ? 'new-password' : 'off'"
size="small"
@blur="touched.add(parameter.name)"
@update:model-value="valueChanged({ name: parameter.name, value: $event })"
/>
<N8nText

View File

@ -1,7 +1,7 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import { fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { CREDENTIAL_BLANKING_VALUE } from 'n8n-workflow';
import TemplatedAuthSimpleView from './TemplatedAuthSimpleView.vue';
@ -36,6 +36,15 @@ const credentialData = (overrides: Record<string, string> = {}) => ({
});
describe('TemplatedAuthSimpleView', () => {
it('shows missing compact recipe values only after submission', async () => {
const view = renderComponent({
props: { compact: true, credentialData: credentialData({ placeholderValues: '{}' }) },
});
await fireEvent.blur(view.getByLabelText('fal.ai API key'));
expect(view.queryByRole('alert')).toBeNull();
await view.rerender({ showValidationWarnings: true });
expect(view.getAllByRole('alert')).not.toHaveLength(0);
});
it('renders one input per template marker, labeled from the defs', () => {
const { getAllByTestId, getByText } = renderComponent({
props: { credentialData: credentialData() },

View File

@ -44,6 +44,8 @@ const props = defineProps<{
credentialData: ICredentialDataDecryptedObject;
/** Show only the guided inputs inside the setup panel. */
compact?: boolean;
/** Show missing values after the user submits the simplified form. */
showValidationWarnings?: boolean;
}>();
const emit = defineEmits<{
@ -236,6 +238,7 @@ const typeOptions = [
compact
:credential-properties="placeholderProperties"
:credential-data="parameterValues"
:show-validation-warnings="showValidationWarnings"
documentation-url=""
@update="onParameterUpdate"
/>

View File

@ -1276,6 +1276,31 @@ describe('useCredentialOAuth', () => {
}
});
it.each(['success', 'error', 'cancel'])(
'reuses an existing credential after %s without deleting it',
async (outcome) => {
const store = setupSuccessfulOAuthFlow();
store.fetchUsableCredentials.mockResolvedValue([]);
MockBroadcastChannel.failOauth = outcome === 'error';
MockBroadcastChannel.silent = outcome === 'cancel';
vi.useFakeTimers();
try {
const oauth = useCredentialOAuth();
const result = oauth.authorizeExistingCredential(createdCredential, { workflowId: 'wf' });
await vi.advanceTimersByTimeAsync(100);
if (outcome === 'cancel') oauth.cancelAuthorize();
await expect(result).resolves.toEqual(outcome === 'success' ? createdCredential : null);
expect(store.oAuth2Authorize).toHaveBeenCalledWith(createdCredential);
expect(store.createNewCredential).not.toHaveBeenCalled();
expect(store.deleteCredential).not.toHaveBeenCalled();
if (outcome === 'success')
expect(store.fetchUsableCredentials).toHaveBeenCalledWith({ workflowId: 'wf' });
} finally {
vi.useRealTimers();
}
},
);
it('should track "User saved credentials" after OAuth completes, not before', async () => {
setupSuccessfulOAuthFlow();

View File

@ -390,10 +390,11 @@ export function useCredentialOAuth() {
* Create a new OAuth credential and run the full authorization flow.
* Returns the credential on success, null on failure (cleans up automatically).
*/
async function createAndAuthorize(
async function connectCredential(
credentialTypeName: string,
nodeType?: string,
options: CreateAndAuthorizeOptions = {},
existingCredential?: ICredentialsResponse,
): Promise<ICredentialsResponse | null> {
const credentialType = credentialsStore.getCredentialTypeByName(credentialTypeName);
if (!credentialType) {
@ -446,31 +447,35 @@ export function useCredentialOAuth() {
let credential: ICredentialsResponse;
try {
const name =
existingCredential?.name ??
options.name ??
(await credentialsStore.getNewCredentialName({
credentialTypeName,
fallbackName: credentialType.displayName,
}));
credential = await credentialsStore.createNewCredential(
{
id: '',
name,
...(credentialDescriptionsEnabled.value && options.description !== undefined
? { description: options.description }
: {}),
type: credentialTypeName,
data,
},
options.projectId ?? projectsStore.currentProject?.id,
undefined,
{ skipStoreUpdate: true },
);
credential =
existingCredential ??
(await credentialsStore.createNewCredential(
{
id: '',
name,
...(credentialDescriptionsEnabled.value && options.description !== undefined
? { description: options.description }
: {}),
type: credentialTypeName,
data,
},
options.projectId ?? projectsStore.currentProject?.id,
undefined,
{ skipStoreUpdate: true },
));
telemetry.track('User created credentials', {
credential_type: credential.type,
credential_id: credential.id,
workflow_id: options.workflowId ?? workflowsStore.workflowId,
});
if (!existingCredential)
telemetry.track('User created credentials', {
credential_type: credential.type,
credential_id: credential.id,
workflow_id: options.workflowId ?? workflowsStore.workflowId,
});
} catch (error) {
popup.window.close();
oauthAbortController.value = null;
@ -478,7 +483,7 @@ export function useCredentialOAuth() {
return null;
}
pendingCredentialId.value = credential.id;
pendingCredentialId.value = existingCredential ? null : credential.id;
const success = await authorize(credential, controller.signal, { popup }).finally(() => {
authorizationFinished = true;
@ -492,7 +497,7 @@ export function useCredentialOAuth() {
workflow_id: options.workflowId ?? workflowsStore.workflowId ?? null,
credential_id: credential.id,
is_complete: true,
is_new: true,
is_new: !existingCredential,
is_valid: success,
uses_external_secrets: false,
};
@ -501,7 +506,7 @@ export function useCredentialOAuth() {
trackProperties.node_type = nodeType;
}
telemetry.track('User saved credentials', trackProperties);
if (!existingCredential) telemetry.track('User saved credentials', trackProperties);
if (success) {
await publishConnectedCredential(
@ -513,10 +518,25 @@ export function useCredentialOAuth() {
return credential;
}
void credentialsStore.deleteCredential({ id: credential.id });
if (!existingCredential) void credentialsStore.deleteCredential({ id: credential.id });
return null;
}
async function createAndAuthorize(
credentialTypeName: string,
nodeType?: string,
options: CreateAndAuthorizeOptions = {},
) {
return await connectCredential(credentialTypeName, nodeType, options);
}
async function authorizeExistingCredential(
credential: ICredentialsResponse,
options: CreateAndAuthorizeOptions = {},
) {
return await connectCredential(credential.type, undefined, options, credential);
}
/**
* Cancel any in-progress OAuth authorization and clean up the pending credential.
*/
@ -547,6 +567,7 @@ export function useCredentialOAuth() {
authorize,
authorizeNewCredential,
createAndAuthorize,
authorizeExistingCredential,
cancelAuthorize,
};
}

View File

@ -10,7 +10,10 @@ import type { InstanceAiAgentNode, InstanceAiSetupItem } from '@n8n/api-types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import { useSetupPanelState } from '@/features/ai/instanceAi/composables/useSetupPanelState';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ICredentialsResponse } from '@/features/credentials/credentials.types';
import type {
ICredentialsDecryptedResponse,
ICredentialsResponse,
} from '@/features/credentials/credentials.types';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
@ -616,6 +619,244 @@ describe('useWorkflowSetupItems', () => {
).toBe(false);
});
it.each([
'connected',
'disconnected',
'read-only shared credential',
'clientCredentials',
'read-error',
] as const)('derives saved OAuth completion after loading a workflow: %s', async (scenario) => {
const scopes: ICredentialsResponse['scopes'] =
scenario === 'read-only shared credential'
? ['credential:read']
: ['credential:read', 'credential:update'];
const credential = {
id: 'gmail-1',
name: 'Gmail account',
type: 'gmailOAuth2',
scopes,
isManaged: false,
createdAt: '2026-09-22T00:00:00.000Z' as const,
updatedAt: '2026-09-22T00:00:00.000Z' as const,
};
credentialsStore.getCredentialById = vi.fn().mockReturnValue(credential);
credentialsStore.getCredentialTypeByName = vi.fn().mockReturnValue({ extends: ['oAuth2Api'] });
const read = Promise.withResolvers<ICredentialsDecryptedResponse | ICredentialsResponse>();
credentialsStore.getCredentialData.mockReturnValue(read.promise);
workflowsListStore.fetchWorkflow.mockResolvedValue(
createTestWorkflow({
id: WORKFLOW_ID,
nodes: [
createTestNode({
name: 'Gmail',
credentials: { gmailOAuth2: { id: credential.id, name: credential.name } },
}),
],
}),
);
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
const item = credentialItem({
credentialType: 'gmailOAuth2',
nodeBindings: [{ nodeName: 'Gmail' }],
});
expect(state.isItemDone(item)).toBe(false);
if (scenario === 'read-error') read.reject(new Error('Request failed'));
else
read.resolve({
...credential,
data:
scenario === 'read-only shared credential'
? undefined
: {
grantType:
scenario === 'clientCredentials' ? 'clientCredentials' : 'authorizationCode',
oauthTokenData: scenario === 'connected',
},
});
await flushPromises();
expect(state.isItemDone(item)).toBe(!['disconnected', 'read-error'].includes(scenario));
});
describe('OAuth connection refreshes', () => {
const first: ICredentialsResponse = {
id: 'gmail-1',
name: 'First Gmail account',
type: 'gmailOAuth2',
data: '',
isManaged: false,
isResolvable: false,
createdAt: '2026-09-22T00:00:00.000Z',
updatedAt: '2026-09-22T00:00:00.000Z',
scopes: ['credential:read', 'credential:update'],
};
const second: ICredentialsResponse = { ...first, id: 'gmail-2', name: 'Second Gmail account' };
let workflow: IWorkflowDb;
beforeEach(() => {
credentialsStore.setCredentials([first, second]);
credentialsStore.getCredentialTypeByName = vi
.fn()
.mockReturnValue({ extends: ['oAuth2Api'] });
credentialsStore.getCredentialData.mockImplementation(async ({ id }) => ({
...first,
id,
data: { oauthTokenData: true },
}));
workflow = createTestWorkflow({
id: WORKFLOW_ID,
nodes: [
createTestNode({
name: 'First Gmail',
credentials: { gmailOAuth2: { id: first.id, name: first.name } },
}),
createTestNode({
name: 'Second Gmail',
credentials: { gmailOAuth2: { id: second.id, name: second.name } },
}),
createTestNode({ name: 'Unrelated' }),
],
});
workflowsListStore.fetchWorkflow.mockImplementation(async () => structuredClone(workflow));
});
it.each([
'add node',
'remove node',
'reorder nodes',
'workflow refresh',
'credential list',
'unrelated upsert',
'scope order',
])('preserves OAuth completion without refetching after an unrelated %s', async (change) => {
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2);
const pending = Promise.withResolvers<ICredentialsDecryptedResponse>();
credentialsStore.getCredentialData.mockReturnValue(pending.promise);
if (change === 'add node') workflow.nodes.push(createTestNode({ name: 'Another node' }));
if (change === 'remove node') workflow.nodes.pop();
if (change === 'reorder nodes') workflow.nodes.reverse();
if (change === 'credential list')
credentialsStore.setCredentials(structuredClone([first, second]));
if (change === 'unrelated upsert')
credentialsStore.upsertCredential({ ...first, id: 'unrelated' });
if (change === 'scope order')
credentialsStore.upsertCredential({
...first,
scopes: ['credential:update', 'credential:read'],
});
await state.refreshWorkflow({ force: true });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(true);
expect(state.isCredentialConfigured(second)).toBe(true);
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2);
pending.resolve({ ...first, data: { oauthTokenData: true } });
await flushPromises();
});
it('keeps an in-flight OAuth read across equivalent workflow refreshes', async () => {
const read = Promise.withResolvers<ICredentialsDecryptedResponse>();
credentialsStore.getCredentialData.mockReturnValue(read.promise);
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
await state.refreshWorkflow({ force: true });
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(2);
read.resolve({ ...first, data: { oauthTokenData: true } });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(true);
expect(state.isCredentialConfigured(second)).toBe(true);
});
it.each(['revision', 'permissions'])(
'refreshes only the credential whose %s changed',
async (change) => {
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
const read = Promise.withResolvers<ICredentialsDecryptedResponse | ICredentialsResponse>();
credentialsStore.getCredentialData.mockReturnValue(read.promise);
credentialsStore.upsertCredential({
...first,
...(change === 'revision'
? { updatedAt: '2026-09-23T00:00:00.000Z' as const }
: { scopes: ['credential:read'] }),
});
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(3);
expect(credentialsStore.getCredentialData).toHaveBeenLastCalledWith({ id: first.id });
expect(state.isCredentialConfigured(first)).toBe(false);
expect(state.isCredentialConfigured(second)).toBe(true);
read.resolve({ ...first, data: change === 'permissions' ? undefined : {} });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(change === 'permissions');
},
);
it('retries failed reads without refetching connected credentials', async () => {
credentialsStore.getCredentialData.mockRejectedValueOnce(new Error('offline'));
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(false);
expect(state.isCredentialConfigured(second)).toBe(true);
await state.refreshWorkflow({ force: true });
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(3);
expect(credentialsStore.getCredentialData).toHaveBeenLastCalledWith({ id: first.id });
expect(state.isCredentialConfigured(first)).toBe(true);
});
it('ignores an older read after a credential is removed and bound again', async () => {
const older = Promise.withResolvers<ICredentialsDecryptedResponse>();
credentialsStore.getCredentialData.mockReturnValueOnce(older.promise);
const state = useWorkflowSetupItems(() => WORKFLOW_ID);
await flushPromises();
const removed = workflow.nodes[0];
workflow.nodes = workflow.nodes.slice(1);
await state.refreshWorkflow({ force: true });
await flushPromises();
expect(state.isCredentialConfigured(second)).toBe(true);
credentialsStore.getCredentialData.mockResolvedValueOnce({ ...first, data: {} });
workflow.nodes.push(removed);
await state.refreshWorkflow({ force: true });
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(3);
expect(state.isCredentialConfigured(first)).toBe(false);
older.resolve({ ...first, data: { oauthTokenData: true } });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(false);
});
it('refreshes credentials on a workflow switch and ignores the previous workflow read', async () => {
const older = Promise.withResolvers<ICredentialsDecryptedResponse>();
credentialsStore.getCredentialData.mockReturnValueOnce(older.promise);
const workflowId = ref(WORKFLOW_ID);
const state = useWorkflowSetupItems(workflowId);
await flushPromises();
credentialsStore.getCredentialData.mockResolvedValueOnce({ ...first, data: {} });
workflow.id = 'another-workflow';
workflowId.value = workflow.id;
await flushPromises();
expect(credentialsStore.getCredentialData).toHaveBeenCalledTimes(4);
expect(state.isCredentialConfigured(first)).toBe(false);
older.resolve({ ...first, data: { oauthTokenData: true } });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(false);
});
it('does not restore OAuth state after disposal', async () => {
const read = Promise.withResolvers<ICredentialsDecryptedResponse>();
credentialsStore.getCredentialData.mockReturnValue(read.promise);
const scope = effectScope();
const state = scope.run(() => useWorkflowSetupItems(() => WORKFLOW_ID))!;
await flushPromises();
scope.stop();
read.resolve({ ...first, data: { oauthTokenData: true } });
await flushPromises();
expect(state.isCredentialConfigured(first)).toBe(false);
});
});
it('does not treat a legacy credential name as a saved binding', () => {
const node = createTestNode({ name: 'Slack' });
Object.assign(node, { credentials: { slackApi: 'Legacy account' } });

View File

@ -1,4 +1,12 @@
import { computed, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from 'vue';
import {
computed,
onScopeDispose,
ref,
shallowReactive,
toValue,
watch,
type MaybeRefOrGetter,
} from 'vue';
import { GENERIC_AUTH_CREDENTIAL_TYPES, type InstanceAiSetupItem } from '@n8n/api-types';
import { findPlaceholderDetails } from '@n8n/utils/placeholder';
@ -11,6 +19,8 @@ import {
listenForCredentialChanges,
useCredentialsStore,
} from '@/features/credentials/credentials.store';
import { useCredentialOAuth } from '@/features/credentials/composables/useCredentialOAuth';
import { hasOAuthTokenData } from '@/features/credentials/composables/oauthCallback';
import {
createWorkflowDocumentId,
deriveHomeProject,
@ -21,6 +31,8 @@ import {
getNodeParametersIssues,
} from '@/features/setupPanel/setupPanel.utils';
type OAuthConnectionStatus = 'loading' | 'connected' | 'disconnected' | 'unknown' | 'error';
/**
* A legacy credential name must be replaced with a stored credential reference.
*/
@ -49,6 +61,10 @@ export function useWorkflowSetupItems(
) {
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
const oauth = useCredentialOAuth();
const oauthConnections = shallowReactive(
new Map<string, { key: string; status: OAuthConnectionStatus }>(),
);
const workflowsListStore = useWorkflowsListStore();
const workflowsStore = useWorkflowsStore();
const credentialsLoadedForWorkflow = ref<string>();
@ -79,7 +95,10 @@ export function useWorkflowSetupItems(
const fetchedWorkflow = ref<IWorkflowDb>();
const isRefreshingWorkflow = ref(false);
let workflowFetchVersion = 0;
onScopeDispose(() => workflowFetchVersion++);
onScopeDispose(() => {
workflowFetchVersion++;
oauthConnections.clear();
});
async function refreshWorkflow({ force = false } = {}) {
const id = toValue(workflowId);
@ -178,6 +197,71 @@ export function useWorkflowSetupItems(
return id && fetchedWorkflow.value?.id === id ? fetchedWorkflow.value.nodes : undefined;
});
const boundOAuthCredentials = computed(() => {
const ids = new Set(
(workflowNodes.value ?? []).flatMap((node) =>
Object.values(node.credentials ?? {}).map((credential) => credential.id),
),
);
return [...ids].flatMap((id) => {
const credential = getBoundCredential(id);
return credential && !credential.isResolvable && oauth.isOAuthCredentialType(credential.type)
? [
{
id: credential.id,
key: JSON.stringify([
toValue(workflowId),
credential.type,
credential.updatedAt,
[...(credential.scopes ?? [])].sort(),
]),
},
]
: [];
});
});
watch(
boundOAuthCredentials,
async (credentials) => {
const ids = new Set(credentials.map(({ id }) => id));
for (const id of oauthConnections.keys()) {
if (!ids.has(id)) oauthConnections.delete(id);
}
await Promise.all(
credentials.map(async ({ id, key }) => {
const cached = oauthConnections.get(id);
// Equivalent workflow/store updates must preserve results and in-flight reads.
if (cached?.key === key && cached.status !== 'error') return;
const connection = { key, status: 'loading' as const };
oauthConnections.set(id, connection);
let status: OAuthConnectionStatus = 'error';
try {
const loaded = await credentialsStore.getCredentialData({ id });
const data = loaded?.data;
if (data && typeof data === 'object') {
// Other grants obtain tokens without a user sign-in.
status =
Boolean(
data.grantType && !['authorizationCode', 'pkce'].includes(String(data.grantType)),
) || hasOAuthTokenData(loaded)
? 'connected'
: 'disconnected';
} else {
// Shared credentials without data keep the existing completion behavior.
// Add a scoped status API if setup must verify their authorization.
status = 'unknown';
}
} catch {
// A failed read cannot confirm completion. Retry on the next credential refresh.
}
// A removed or changed credential must not accept an older response.
if (oauthConnections.get(id) === connection) oauthConnections.set(id, { key, status });
}),
);
},
{ immediate: true },
);
/**
* Node state is available from a hydrated canvas store or the fetched
* workflow. Node types must be in too: deriving without them drops
@ -313,15 +397,23 @@ export function useWorkflowSetupItems(
return items;
});
function getBoundCredential(credentialId: string | null | undefined) {
const id = toValue(workflowId);
return credentialId
? ((id && credentialsStore.hasUsableCredentialsForScope({ workflowId: id })
? credentialsStore.getUsableCredentialById(credentialId)
: undefined) ?? credentialsStore.getCredentialById(credentialId))
: undefined;
}
function isCredentialConfigured(assigned: INodeCredentialsDetails | string | undefined): boolean {
if (!isBoundCredential(assigned)) return false;
const id = toValue(workflowId);
const credential =
typeof assigned !== 'string' && assigned?.id
? ((id && credentialsStore.hasUsableCredentialsForScope({ workflowId: id })
? credentialsStore.getUsableCredentialById(assigned.id)
: undefined) ?? credentialsStore.getCredentialById(assigned.id))
: undefined;
const credential = getBoundCredential(typeof assigned !== 'string' ? assigned?.id : undefined);
if (credential && !credential.isResolvable && oauth.isOAuthCredentialType(credential.type)) {
// Wait for the redacted token flag. Missing data remains unknown for shared credentials.
const status = oauthConnections.get(credential.id)?.status;
return status === 'connected' || status === 'unknown';
}
return !credential?.isResolvable || credential.connectedByMe !== false;
}