diff --git a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue
index dd20c63d946..2fe05a1adcb 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue
+++ b/packages/frontend/@n8n/design-system/src/components/N8nSetupConnection/SetupConnection.vue
@@ -63,7 +63,7 @@ const { t } = useI18n();
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();
+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(() => {
? [{ 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(() => {
@@ -678,7 +775,16 @@ onScopeDispose(() => {
{{ helpLabel }}
-
+ {{ value }}
+