mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(editor): Improve n8n Assistant async credential setup (no-changelog) (#39229)
This commit is contained in:
parent
dbc822ba8b
commit
1e1f3cb711
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user