feat: External Secrets storage for credentials (#6477)
Github issue / Community forum post (link here to close automatically): --------- Co-authored-by: Romain Minaud <romain.minaud@gmail.com> Co-authored-by: Valya Bullions <valya@n8n.io> Co-authored-by: Csaba Tuncsik <csaba@n8n.io> Co-authored-by: Giulio Andreini <g.andreini@gmail.com> Co-authored-by: Omar Ajoue <krynble@gmail.com>
This commit is contained in:
@@ -172,6 +172,7 @@ export const completerExtension = defineComponent({
|
||||
|
||||
if (value === '$execution') return this.executionCompletions(context, variable);
|
||||
if (value === '$vars') return this.variablesCompletions(context, variable);
|
||||
|
||||
if (value === '$workflow') return this.workflowCompletions(context, variable);
|
||||
if (value === '$prevNode') return this.prevNodeCompletions(context, variable);
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Vue from 'vue';
|
||||
import { addVarType } from '../utils';
|
||||
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
|
||||
import type { CodeNodeEditorMixin } from '../types';
|
||||
import { useExternalSecretsStore } from '@/stores';
|
||||
|
||||
const escape = (str: string) => str.replace('$', '\\$');
|
||||
|
||||
export const secretsCompletions = (Vue as CodeNodeEditorMixin).extend({
|
||||
methods: {
|
||||
/**
|
||||
* Complete `$secrets.` to `$secrets.providerName` and `$secrets.providerName.secretName`.
|
||||
*/
|
||||
secretsCompletions(context: CompletionContext, matcher = '$secrets'): CompletionResult | null {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
const preCursor = context.matchBefore(pattern);
|
||||
|
||||
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
|
||||
|
||||
const provider = preCursor.text.split('.')[1];
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
let options: Completion[];
|
||||
|
||||
const optionsForObject = (leftSide: string, object: object): Completion[] => {
|
||||
return Object.entries(object).flatMap(([key, value]) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return optionsForObject(`${leftSide}.${key}`, value);
|
||||
}
|
||||
return {
|
||||
label: `${leftSide}.${key}`,
|
||||
info: '*******',
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
if (provider) {
|
||||
options = optionsForObject(
|
||||
`${matcher}.${provider}`,
|
||||
externalSecretsStore.secretsAsObject[provider],
|
||||
);
|
||||
} else {
|
||||
options = Object.keys(externalSecretsStore.secretsAsObject).map((provider) => ({
|
||||
label: `${matcher}.${provider}`,
|
||||
info: JSON.stringify(externalSecretsStore.secretsAsObject[provider]),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
from: preCursor.from,
|
||||
options: options.map(addVarType),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const escape = (str: string) => str.replace('$', '\\$');
|
||||
export const variablesCompletions = defineComponent({
|
||||
methods: {
|
||||
/**
|
||||
* Complete `$workflow.` to `.id .name .active`.
|
||||
* Complete `$vars.` to `$vars.VAR_NAME`.
|
||||
*/
|
||||
variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null {
|
||||
const pattern = new RegExp(`${escape(matcher)}\..*`);
|
||||
|
||||
@@ -126,6 +126,17 @@
|
||||
<n8n-text v-if="isMissingCredentials" color="text-base" size="medium">
|
||||
{{ $locale.baseText('credentialEdit.credentialConfig.missingCredentialType') }}
|
||||
</n8n-text>
|
||||
|
||||
<EnterpriseEdition :features="[EnterpriseEditionFeature.ExternalSecrets]">
|
||||
<template #fallback>
|
||||
<n8n-info-tip class="mt-s">
|
||||
{{ $locale.baseText('credentialEdit.credentialConfig.externalSecrets') }}
|
||||
<n8n-link bold :to="$locale.baseText('settings.externalSecrets.docs')" size="small">
|
||||
{{ $locale.baseText('credentialEdit.credentialConfig.externalSecrets.moreInfo') }}
|
||||
</n8n-link>
|
||||
</n8n-info-tip>
|
||||
</template>
|
||||
</EnterpriseEdition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -152,10 +163,12 @@ import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
import AuthTypeSelector from '@/components/CredentialEdit/AuthTypeSelector.vue';
|
||||
import GoogleAuthButton from './GoogleAuthButton.vue';
|
||||
import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'CredentialConfig',
|
||||
components: {
|
||||
EnterpriseEdition,
|
||||
AuthTypeSelector,
|
||||
Banner,
|
||||
CopyInput,
|
||||
|
||||
@@ -157,6 +157,8 @@ import {
|
||||
getNodeCredentialForSelectedAuthType,
|
||||
updateNodeAuthType,
|
||||
isCredentialModalState,
|
||||
isExpression,
|
||||
isTestableExpression,
|
||||
} from '@/utils';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
|
||||
@@ -370,12 +372,13 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
const { ownedBy, sharedWith, ...credentialData } = this.credentialData;
|
||||
const hasExpressions = Object.values(credentialData).reduce(
|
||||
const hasUntestableExpressions = Object.values(credentialData).reduce(
|
||||
(accu: boolean, value: CredentialInformation) =>
|
||||
accu || (typeof value === 'string' && value.startsWith('=')),
|
||||
accu ||
|
||||
(typeof value === 'string' && isExpression(value) && !isTestableExpression(value)),
|
||||
false,
|
||||
);
|
||||
if (hasExpressions) {
|
||||
if (hasUntestableExpressions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -445,8 +448,14 @@ export default defineComponent({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (property.type === 'number' && typeof this.credentialData[property.name] !== 'number') {
|
||||
return false;
|
||||
if (property.type === 'number') {
|
||||
const isExpression =
|
||||
typeof this.credentialData[property.name] === 'string' &&
|
||||
this.credentialData[property.name].startsWith('=');
|
||||
|
||||
if (typeof this.credentialData[property.name] !== 'number' && !isExpression) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -835,12 +844,17 @@ export default defineComponent({
|
||||
this.testedSuccessfully = false;
|
||||
}
|
||||
|
||||
const usesExternalSecrets = Object.entries(credentialDetails.data || {}).some(([, value]) =>
|
||||
/=.*\{\{[^}]*\$secrets\.[^}]+}}.*/.test(`${value}`),
|
||||
);
|
||||
|
||||
const trackProperties: ITelemetryTrackProperties = {
|
||||
credential_type: credentialDetails.type,
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
credential_id: credential.id,
|
||||
is_complete: !!this.requiredPropertiesFilled,
|
||||
is_new: isNewCredential,
|
||||
uses_external_secrets: usesExternalSecrets,
|
||||
};
|
||||
|
||||
if (this.isOAuthType) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
:isReadOnly="isReadOnly"
|
||||
:targetItem="hoveringItem"
|
||||
:isSingleLine="isForRecordLocator"
|
||||
:additionalData="additionalExpressionData"
|
||||
:path="path"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@@ -34,7 +35,6 @@
|
||||
data-test-id="expander"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<InlineExpressionEditorOutput
|
||||
:segments="segments"
|
||||
:isReadOnly="isReadOnly"
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { mapStores } from 'pinia';
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
@@ -57,6 +58,7 @@ import { createExpressionTelemetryPayload } from '@/utils/telemetryUtils';
|
||||
|
||||
import type { Segment } from '@/types/expressions';
|
||||
import type { TargetItem } from '@/Interface';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
type InlineExpressionEditorInputRef = InstanceType<typeof InlineExpressionEditorInput>;
|
||||
|
||||
@@ -88,6 +90,10 @@ export default defineComponent({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
additionalExpressionData: {
|
||||
type: Object as PropType<IDataObject>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore, useWorkflowsStore),
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType, Ref } from 'vue';
|
||||
import type { ExternalSecretsProvider } from '@/Interface';
|
||||
import ExternalSecretsProviderImage from '@/components/ExternalSecretsProviderImage.ee.vue';
|
||||
import ExternalSecretsProviderConnectionSwitch from '@/components/ExternalSecretsProviderConnectionSwitch.ee.vue';
|
||||
import { useExternalSecretsStore, useUIStore } from '@/stores';
|
||||
import { useExternalSecretsProvider, useI18n, useToast } from '@/composables';
|
||||
import { EXTERNAL_SECRETS_PROVIDER_MODAL_KEY } from '@/constants';
|
||||
import { DateTime } from 'luxon';
|
||||
import { computed, nextTick, onMounted, toRefs } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
provider: {
|
||||
type: Object as PropType<ExternalSecretsProvider>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const i18n = useI18n();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
|
||||
const { provider } = toRefs(props) as Ref<ExternalSecretsProvider>;
|
||||
const providerData = computed(() => provider.value.data);
|
||||
const {
|
||||
connectionState,
|
||||
initialConnectionState,
|
||||
normalizedProviderData,
|
||||
testConnection,
|
||||
setConnectionState,
|
||||
} = useExternalSecretsProvider(provider, providerData);
|
||||
|
||||
const actionDropdownOptions = computed(() => [
|
||||
{
|
||||
value: 'setup',
|
||||
label: i18n.baseText('settings.externalSecrets.card.actionDropdown.setup'),
|
||||
},
|
||||
...(props.provider.connected
|
||||
? [
|
||||
{
|
||||
value: 'reload',
|
||||
label: i18n.baseText('settings.externalSecrets.card.actionDropdown.reload'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const canConnect = computed(() => {
|
||||
return props.provider.connected || Object.keys(props.provider.data).length > 0;
|
||||
});
|
||||
|
||||
const formattedDate = computed((provider: ExternalSecretsProvider) => {
|
||||
return DateTime.fromISO(props.provider.connectedAt!).toFormat('dd LLL yyyy');
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setConnectionState(props.provider.state);
|
||||
});
|
||||
|
||||
async function onBeforeConnectionUpdate() {
|
||||
if (props.provider.connected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await externalSecretsStore.getProvider(props.provider.name);
|
||||
await nextTick();
|
||||
const status = await testConnection();
|
||||
|
||||
return status !== 'error';
|
||||
}
|
||||
|
||||
function openExternalSecretProvider() {
|
||||
uiStore.openModalWithData({
|
||||
name: EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||
data: { name: props.provider.name },
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadProvider() {
|
||||
try {
|
||||
await externalSecretsStore.reloadProvider(props.provider.name);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.externalSecrets.card.reload.success.title'),
|
||||
message: i18n.baseText('settings.externalSecrets.card.reload.success.description', {
|
||||
interpolate: { provider: props.provider.displayName },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onActionDropdownClick(id: string) {
|
||||
switch (id) {
|
||||
case 'setup':
|
||||
openExternalSecretProvider();
|
||||
break;
|
||||
case 'reload':
|
||||
await reloadProvider();
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n8n-card :class="$style.card">
|
||||
<div :class="$style.cardBody">
|
||||
<ExternalSecretsProviderImage :class="$style.cardImage" :provider="provider" />
|
||||
<div :class="$style.cardContent">
|
||||
<n8n-text bold>{{ provider.displayName }}</n8n-text>
|
||||
<n8n-text color="text-light" size="small" v-if="provider.connected">
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('settings.externalSecrets.card.secretsCount', {
|
||||
interpolate: {
|
||||
count: `${externalSecretsStore.secrets[provider.name]?.length}`,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
|
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('settings.externalSecrets.card.connectedAt', {
|
||||
interpolate: {
|
||||
date: formattedDate,
|
||||
},
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.cardActions" v-if="canConnect">
|
||||
<ExternalSecretsProviderConnectionSwitch
|
||||
:provider="provider"
|
||||
:beforeUpdate="onBeforeConnectionUpdate"
|
||||
:disabled="connectionState === 'error' && !provider.connected"
|
||||
/>
|
||||
<n8n-action-toggle
|
||||
class="ml-s"
|
||||
theme="dark"
|
||||
:actions="actionDropdownOptions"
|
||||
@action="onActionDropdownClick"
|
||||
/>
|
||||
</div>
|
||||
<n8n-button v-else type="tertiary" @click="openExternalSecretProvider()">
|
||||
{{ i18n.baseText('settings.externalSecrets.card.setUp') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</n8n-card>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.card {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.cardImage {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cardContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
margin-left: var(--spacing-s);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-left: var(--spacing-s);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { ExternalSecretsProvider } from '@/Interface';
|
||||
import { useExternalSecretsStore } from '@/stores';
|
||||
import { useI18n, useLoadingService, useToast } from '@/composables';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
provider: {
|
||||
type: Object as PropType<ExternalSecretsProvider>,
|
||||
required: true,
|
||||
},
|
||||
eventBus: {
|
||||
type: Object as PropType<EventBus>,
|
||||
default: undefined,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
beforeUpdate: {
|
||||
type: Function,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
const connectedTextColor = computed(() => {
|
||||
return props.provider.connected ? 'success' : 'text-light';
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (props.eventBus) {
|
||||
props.eventBus.on('connect', onUpdateConnected);
|
||||
}
|
||||
});
|
||||
|
||||
async function onUpdateConnected(value: boolean) {
|
||||
try {
|
||||
saving.value = true;
|
||||
|
||||
if (props.beforeUpdate) {
|
||||
const result = await props.beforeUpdate(value);
|
||||
if (result === false) {
|
||||
saving.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await externalSecretsStore.updateProviderConnected(props.provider.name, value);
|
||||
|
||||
emit('change', value);
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="connection-switch" v-loading="saving" element-loading-spinner="el-icon-loading">
|
||||
<n8n-icon
|
||||
v-if="provider.state === 'error'"
|
||||
color="danger"
|
||||
icon="exclamation-triangle"
|
||||
class="mr-2xs"
|
||||
/>
|
||||
<n8n-text :color="connectedTextColor" bold class="mr-2xs">
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.externalSecrets.card.${provider.connected ? 'connected' : 'disconnected'}`,
|
||||
)
|
||||
}}
|
||||
</n8n-text>
|
||||
<el-switch
|
||||
:modelValue="provider.connected"
|
||||
:title="
|
||||
i18n.baseText('settings.externalSecrets.card.connectedSwitch.title', {
|
||||
interpolate: { provider: provider.displayName },
|
||||
})
|
||||
"
|
||||
:disabled="disabled"
|
||||
data-test-id="settings-external-secrets-connected-switch"
|
||||
@update:modelValue="onUpdateConnected"
|
||||
>
|
||||
</el-switch>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.connection-switch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
&.error {
|
||||
:deep(.el-switch.is-checked .el-switch__core) {
|
||||
background-color: #ff4027;
|
||||
border-color: #ff4027;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import type { ExternalSecretsProvider } from '@/Interface';
|
||||
import { computed } from 'vue';
|
||||
import infisical from '../assets/images/infisical.webp';
|
||||
import doppler from '../assets/images/doppler.webp';
|
||||
import vault from '../assets/images/hashicorp.webp';
|
||||
|
||||
const props = defineProps({
|
||||
provider: {
|
||||
type: Object as PropType<ExternalSecretsProvider>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const image = computed(
|
||||
() =>
|
||||
({
|
||||
doppler,
|
||||
infisical,
|
||||
vault,
|
||||
})[props.provider.name],
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<img :src="image" :alt="provider.displayName" width="28" height="28" />
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from './Modal.vue';
|
||||
import { EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, MODAL_CONFIRM } from '@/constants';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { PropType, Ref } from 'vue';
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
import { useExternalSecretsProvider, useI18n, useMessage, useToast } from '@/composables';
|
||||
import { useExternalSecretsStore } from '@/stores/externalSecrets.ee.store';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { useRoute } from 'vue-router';
|
||||
import ParameterInputExpanded from '@/components/ParameterInputExpanded.vue';
|
||||
import type { IUpdateInformation, ExternalSecretsProviderData } from '@/Interface';
|
||||
import type { IParameterLabel } from 'n8n-workflow';
|
||||
import ExternalSecretsProviderImage from '@/components/ExternalSecretsProviderImage.ee.vue';
|
||||
import ExternalSecretsProviderConnectionSwitch from '@/components/ExternalSecretsProviderConnectionSwitch.ee.vue';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
import type { ExternalSecretsProvider } from '@/Interface';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<{ eventBus: EventBus; name: string }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const defaultProviderData = {
|
||||
infisical: {
|
||||
siteURL: 'https://app.infisical.com',
|
||||
},
|
||||
};
|
||||
|
||||
const externalSecretsStore = useExternalSecretsStore();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
const { confirm } = useMessage();
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
const eventBus = createEventBus();
|
||||
|
||||
const labelSize: IParameterLabel = { size: 'medium' };
|
||||
|
||||
const provider = computed<ExternalSecretsProvider | undefined>(() =>
|
||||
externalSecretsStore.providers.find((p) => p.name === props.data.name),
|
||||
) as Ref<ExternalSecretsProvider>;
|
||||
const providerData = ref<ExternalSecretsProviderData>({});
|
||||
const {
|
||||
connectionState,
|
||||
initialConnectionState,
|
||||
normalizedProviderData,
|
||||
shouldDisplayProperty,
|
||||
setConnectionState,
|
||||
testConnection,
|
||||
} = useExternalSecretsProvider(provider, providerData);
|
||||
|
||||
const providerDataUpdated = computed(() => {
|
||||
return Object.keys(providerData.value).find((key) => {
|
||||
const value = providerData.value[key];
|
||||
const originalValue = provider.value.data[key];
|
||||
|
||||
return value !== originalValue;
|
||||
});
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() =>
|
||||
provider.value.properties
|
||||
?.filter((property) => property.required && shouldDisplayProperty(property))
|
||||
.every((property) => {
|
||||
const value = providerData.value[property.name];
|
||||
return !!value;
|
||||
}) && providerDataUpdated.value,
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const provider = await externalSecretsStore.getProvider(props.data.name);
|
||||
providerData.value = {
|
||||
...(defaultProviderData[props.data.name] || {}),
|
||||
...provider.data,
|
||||
};
|
||||
|
||||
setConnectionState(provider.state);
|
||||
|
||||
if (provider.connected) {
|
||||
initialConnectionState.value = provider.state;
|
||||
} else if (Object.keys(provider.data).length) {
|
||||
await testConnection();
|
||||
}
|
||||
|
||||
if (provider.state === 'connected') {
|
||||
void externalSecretsStore.reloadProvider(props.data.name);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error');
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
uiStore.closeModal(EXTERNAL_SECRETS_PROVIDER_MODAL_KEY);
|
||||
}
|
||||
|
||||
function onValueChange(updateInformation: IUpdateInformation) {
|
||||
providerData.value = {
|
||||
...providerData.value,
|
||||
[updateInformation.name]: updateInformation.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true;
|
||||
await externalSecretsStore.updateProvider(provider.value.name, {
|
||||
data: normalizedProviderData.value,
|
||||
});
|
||||
|
||||
setConnectionState(provider.value.state);
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error');
|
||||
}
|
||||
|
||||
await testConnection();
|
||||
|
||||
if (initialConnectionState.value === 'initializing' && connectionState.value === 'tested') {
|
||||
setTimeout(() => {
|
||||
eventBus.emit('connect', true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
}
|
||||
|
||||
async function onBeforeClose() {
|
||||
if (providerDataUpdated.value) {
|
||||
const confirmModal = await confirm(
|
||||
i18n.baseText('settings.externalSecrets.provider.closeWithoutSaving.description', {
|
||||
interpolate: {
|
||||
provider: provider.value.displayName,
|
||||
},
|
||||
}),
|
||||
{
|
||||
title: i18n.baseText('settings.externalSecrets.provider.closeWithoutSaving.title'),
|
||||
confirmButtonText: i18n.baseText(
|
||||
'settings.externalSecrets.provider.closeWithoutSaving.confirm',
|
||||
),
|
||||
cancelButtonText: i18n.baseText(
|
||||
'settings.externalSecrets.provider.closeWithoutSaving.cancel',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
return confirmModal !== MODAL_CONFIRM;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
id="external-secrets-provider-modal"
|
||||
width="812px"
|
||||
:title="provider.displayName"
|
||||
:eventBus="data.eventBus"
|
||||
:name="EXTERNAL_SECRETS_PROVIDER_MODAL_KEY"
|
||||
:before-close="onBeforeClose"
|
||||
>
|
||||
<template #header>
|
||||
<div :class="$style.header">
|
||||
<div :class="$style.providerTitle">
|
||||
<ExternalSecretsProviderImage :provider="provider" class="mr-xs" />
|
||||
<span>{{ provider.displayName }}</span>
|
||||
</div>
|
||||
<div :class="$style.providerActions">
|
||||
<ExternalSecretsProviderConnectionSwitch
|
||||
class="mr-s"
|
||||
:disabled="
|
||||
(connectionState === 'initializing' || connectionState === 'error') &&
|
||||
!provider.connected
|
||||
"
|
||||
:event-bus="eventBus"
|
||||
:provider="provider"
|
||||
@change="testConnection"
|
||||
/>
|
||||
<n8n-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!canSave && !saving"
|
||||
@click="save"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.externalSecrets.provider.buttons.${saving ? 'saving' : 'save'}`,
|
||||
)
|
||||
}}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<hr class="mb-l" />
|
||||
<div class="mb-l" v-if="connectionState !== 'initializing'">
|
||||
<n8n-callout
|
||||
v-if="connectionState === 'connected' || connectionState === 'tested'"
|
||||
theme="success"
|
||||
>
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.externalSecrets.provider.testConnection.success${
|
||||
provider.connected ? '.connected' : ''
|
||||
}`,
|
||||
{
|
||||
interpolate: {
|
||||
count: `${externalSecretsStore.secrets[provider.name]?.length}`,
|
||||
provider: provider.displayName,
|
||||
},
|
||||
},
|
||||
)
|
||||
}}
|
||||
<span v-if="provider.connected">
|
||||
<br />
|
||||
<i18n-t
|
||||
keypath="settings.externalSecrets.provider.testConnection.success.connected.usage"
|
||||
>
|
||||
<template #code>
|
||||
<code>{{ `\{\{ \$secrets\.${provider.name}\.secret_name \}\}` }}</code>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<n8n-link :href="i18n.baseText('settings.externalSecrets.docs.use')" size="small">
|
||||
{{
|
||||
i18n.baseText(
|
||||
'settings.externalSecrets.provider.testConnection.success.connected.docs',
|
||||
)
|
||||
}}
|
||||
</n8n-link>
|
||||
</span>
|
||||
</n8n-callout>
|
||||
<n8n-callout v-else-if="connectionState === 'error'" theme="danger">
|
||||
{{
|
||||
i18n.baseText(
|
||||
`settings.externalSecrets.provider.testConnection.error${
|
||||
provider.connected ? '.connected' : ''
|
||||
}`,
|
||||
{
|
||||
interpolate: { provider: provider.displayName },
|
||||
},
|
||||
)
|
||||
}}
|
||||
</n8n-callout>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-for="property in provider.properties"
|
||||
v-show="shouldDisplayProperty(property)"
|
||||
:key="property.name"
|
||||
autocomplete="off"
|
||||
data-test-id="external-secrets-provider-properties-form"
|
||||
@submit.prevent
|
||||
>
|
||||
<n8n-notice v-if="property.type === 'notice'" :content="property.displayName" />
|
||||
<parameter-input-expanded
|
||||
v-else
|
||||
class="mb-l"
|
||||
:parameter="property"
|
||||
:value="providerData[property.name]"
|
||||
:label="labelSize"
|
||||
eventSource="external-secrets-provider"
|
||||
@update="onValueChange"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
> * {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.providerTitle {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.providerActions {
|
||||
flex: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
#external-secrets-provider-modal {
|
||||
.el-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.el-dialog__headerbtn {
|
||||
position: relative;
|
||||
top: unset;
|
||||
right: unset;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { EditorView, keymap } from '@codemirror/view';
|
||||
import { Compartment, EditorState, Prec } from '@codemirror/state';
|
||||
@@ -18,6 +19,7 @@ import { expressionInputHandler } from '@/plugins/codemirror/inputHandlers/expre
|
||||
import { inputTheme } from './theme';
|
||||
import { n8nLang } from '@/plugins/codemirror/n8nLang';
|
||||
import { completionManager } from '@/mixins/completionManager';
|
||||
import type { IDataObject } from 'n8n-workflow';
|
||||
|
||||
const editableConf = new Compartment();
|
||||
|
||||
@@ -39,6 +41,10 @@ export default defineComponent({
|
||||
path: {
|
||||
type: String,
|
||||
},
|
||||
additionalData: {
|
||||
type: Object as PropType<IDataObject>,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
isReadOnly(newValue: boolean) {
|
||||
@@ -83,6 +89,7 @@ export default defineComponent({
|
||||
},
|
||||
mounted() {
|
||||
const extensions = [
|
||||
n8nLang(),
|
||||
inputTheme({ isSingleLine: this.isSingleLine }),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
@@ -100,7 +107,6 @@ export default defineComponent({
|
||||
]),
|
||||
),
|
||||
autocompletion(),
|
||||
n8nLang(),
|
||||
history(),
|
||||
expressionInputHandler(),
|
||||
EditorView.lineWrapping,
|
||||
|
||||
@@ -120,6 +120,12 @@
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="EXTERNAL_SECRETS_PROVIDER_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<ExternalSecretsProviderModal :modalName="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="DEBUG_PAYWALL_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<DebugPaywallModal data-test-id="debug-paywall-modal" :modalName="modalName" :data="data" />
|
||||
@@ -153,6 +159,7 @@ import {
|
||||
LOG_STREAM_MODAL_KEY,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||
DEBUG_PAYWALL_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
@@ -181,6 +188,7 @@ import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
|
||||
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
|
||||
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -209,6 +217,7 @@ export default defineComponent({
|
||||
EventDestinationSettingsModal,
|
||||
SourceControlPushModal,
|
||||
SourceControlPullModal,
|
||||
ExternalSecretsProviderModal,
|
||||
DebugPaywallModal,
|
||||
MfaSetupModal,
|
||||
},
|
||||
@@ -235,6 +244,7 @@ export default defineComponent({
|
||||
LOG_STREAM_MODAL_KEY,
|
||||
SOURCE_CONTROL_PUSH_MODAL_KEY,
|
||||
SOURCE_CONTROL_PULL_MODAL_KEY,
|
||||
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
|
||||
DEBUG_PAYWALL_MODAL_KEY,
|
||||
MFA_SETUP_MODAL_KEY,
|
||||
}),
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
:title="displayTitle"
|
||||
:isReadOnly="isReadOnly"
|
||||
:path="path"
|
||||
:additional-expression-data="additionalExpressionData"
|
||||
:class="{ 'ph-no-capture': shouldRedactValue }"
|
||||
@update:modelValue="expressionUpdated"
|
||||
@modalOpenerClick="openExpressionEditorModal"
|
||||
@@ -366,6 +367,7 @@ import type {
|
||||
IParameterLabel,
|
||||
EditorType,
|
||||
CodeNodeEditorLanguage,
|
||||
IDataObject,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers, CREDENTIAL_EMPTY_VALUE } from 'n8n-workflow';
|
||||
|
||||
@@ -411,6 +413,10 @@ export default defineComponent({
|
||||
TextEdit,
|
||||
},
|
||||
props: {
|
||||
additionalExpressionData: {
|
||||
type: Object as PropType<IDataObject>,
|
||||
default: () => ({}),
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
|
||||
@@ -66,8 +66,6 @@ import { mapStores } from 'pinia';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
|
||||
type ParamRef = InstanceType<typeof ParameterInputWrapper>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'parameter-input-expanded',
|
||||
components: {
|
||||
@@ -116,6 +114,10 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
if (this.parameter.type === 'number') {
|
||||
if (typeof this.value === 'string' && this.value.startsWith('=')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return typeof this.value !== 'number';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
:isForCredential="isForCredential"
|
||||
:eventSource="eventSource"
|
||||
:expressionEvaluated="expressionValueComputed"
|
||||
:additionalExpressionData="resolvedAdditionalExpressionData"
|
||||
:label="label"
|
||||
:data-test-id="`parameter-input-${parameter.name}`"
|
||||
:event-bus="eventBus"
|
||||
@@ -50,6 +51,7 @@ import { mapStores } from 'pinia';
|
||||
import ParameterInput from '@/components/ParameterInput.vue';
|
||||
import InputHint from '@/components/ParameterInputHint.vue';
|
||||
import type {
|
||||
IDataObject,
|
||||
INodeProperties,
|
||||
INodePropertyMode,
|
||||
IParameterLabel,
|
||||
@@ -61,6 +63,8 @@ import type { INodeUi, IUpdateInformation, TargetItem } from '@/Interface';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { isValueExpression } from '@/utils';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useEnvironmentsStore, useExternalSecretsStore } from '@/stores';
|
||||
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
|
||||
@@ -72,6 +76,10 @@ export default defineComponent({
|
||||
InputHint,
|
||||
},
|
||||
props: {
|
||||
additionalExpressionData: {
|
||||
type: Object as PropType<IDataObject>,
|
||||
default: () => ({}),
|
||||
},
|
||||
isReadOnly: {
|
||||
type: Boolean,
|
||||
},
|
||||
@@ -127,7 +135,7 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore),
|
||||
...mapStores(useNDVStore, useExternalSecretsStore, useEnvironmentsStore),
|
||||
isValueExpression() {
|
||||
return isValueExpression(this.parameter, this.modelValue);
|
||||
},
|
||||
@@ -183,6 +191,7 @@ export default defineComponent({
|
||||
inputNodeName: this.ndvStore.ndvInputNodeName,
|
||||
inputRunIndex: this.ndvStore.ndvInputRunIndex,
|
||||
inputBranchIndex: this.ndvStore.ndvInputBranchIndex,
|
||||
additionalKeys: this.resolvedAdditionalExpressionData,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -208,6 +217,15 @@ export default defineComponent({
|
||||
|
||||
return null;
|
||||
},
|
||||
resolvedAdditionalExpressionData() {
|
||||
return {
|
||||
$vars: this.environmentsStore.variablesAsObject,
|
||||
...(this.externalSecretsStore.isEnterpriseExternalSecretsEnabled && this.isForCredential
|
||||
? { $secrets: this.externalSecretsStore.secretsAsObject }
|
||||
: {}),
|
||||
...this.additionalExpressionData,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onFocus() {
|
||||
|
||||
@@ -74,6 +74,17 @@ export default defineComponent({
|
||||
available: this.canAccessApiSettings(),
|
||||
activateOnRouteNames: [VIEWS.API_SETTINGS],
|
||||
},
|
||||
{
|
||||
id: 'settings-external-secrets',
|
||||
icon: 'vault',
|
||||
label: this.$locale.baseText('settings.externalSecrets.title'),
|
||||
position: 'top',
|
||||
available: this.canAccessExternalSecrets(),
|
||||
activateOnRouteNames: [
|
||||
VIEWS.EXTERNAL_SECRETS_SETTINGS,
|
||||
VIEWS.EXTERNAL_SECRETS_PROVIDER_SETTINGS,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings-audit-logs',
|
||||
icon: 'clipboard-list',
|
||||
@@ -164,6 +175,9 @@ export default defineComponent({
|
||||
canAccessUsageAndPlan(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.USAGE);
|
||||
},
|
||||
canAccessExternalSecrets(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.EXTERNAL_SECRETS_SETTINGS);
|
||||
},
|
||||
canAccessSourceControl(): boolean {
|
||||
return this.canUserAccessRouteByName(VIEWS.SOURCE_CONTROL);
|
||||
},
|
||||
@@ -179,51 +193,43 @@ export default defineComponent({
|
||||
openUpdatesPanel() {
|
||||
this.uiStore.openModal(VERSIONS_MODAL_KEY);
|
||||
},
|
||||
async navigateTo(routeName: (typeof VIEWS)[keyof typeof VIEWS]) {
|
||||
if (this.$router.currentRoute.name !== routeName) {
|
||||
await this.$router.push({ name: routeName });
|
||||
}
|
||||
},
|
||||
async handleSelect(key: string) {
|
||||
switch (key) {
|
||||
case 'settings-personal':
|
||||
if (this.$router.currentRoute.name !== VIEWS.PERSONAL_SETTINGS) {
|
||||
await this.$router.push({ name: VIEWS.PERSONAL_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.PERSONAL_SETTINGS);
|
||||
break;
|
||||
case 'settings-users':
|
||||
if (this.$router.currentRoute.name !== VIEWS.USERS_SETTINGS) {
|
||||
await this.$router.push({ name: VIEWS.USERS_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.USERS_SETTINGS);
|
||||
break;
|
||||
case 'settings-api':
|
||||
if (this.$router.currentRoute.name !== VIEWS.API_SETTINGS) {
|
||||
await this.$router.push({ name: VIEWS.API_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.API_SETTINGS);
|
||||
break;
|
||||
case 'settings-ldap':
|
||||
if (this.$router.currentRoute.name !== VIEWS.LDAP_SETTINGS) {
|
||||
void this.$router.push({ name: VIEWS.LDAP_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.LDAP_SETTINGS);
|
||||
break;
|
||||
case 'settings-log-streaming':
|
||||
if (this.$router.currentRoute.name !== VIEWS.LOG_STREAMING_SETTINGS) {
|
||||
void this.$router.push({ name: VIEWS.LOG_STREAMING_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.LOG_STREAMING_SETTINGS);
|
||||
break;
|
||||
case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
|
||||
case 'logging':
|
||||
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
|
||||
break;
|
||||
case 'settings-community-nodes':
|
||||
if (this.$router.currentRoute.name !== VIEWS.COMMUNITY_NODES) {
|
||||
await this.$router.push({ name: VIEWS.COMMUNITY_NODES });
|
||||
}
|
||||
await this.navigateTo(VIEWS.COMMUNITY_NODES);
|
||||
break;
|
||||
case 'settings-usage-and-plan':
|
||||
if (this.$router.currentRoute.name !== VIEWS.USAGE) {
|
||||
void this.$router.push({ name: VIEWS.USAGE });
|
||||
}
|
||||
await this.navigateTo(VIEWS.USAGE);
|
||||
break;
|
||||
case 'settings-sso':
|
||||
if (this.$router.currentRoute.name !== VIEWS.SSO_SETTINGS) {
|
||||
void this.$router.push({ name: VIEWS.SSO_SETTINGS });
|
||||
}
|
||||
await this.navigateTo(VIEWS.SSO_SETTINGS);
|
||||
break;
|
||||
case 'settings-external-secrets':
|
||||
await this.navigateTo(VIEWS.EXTERNAL_SECRETS_SETTINGS);
|
||||
break;
|
||||
case 'settings-source-control':
|
||||
if (this.$router.currentRoute.name !== VIEWS.SOURCE_CONTROL) {
|
||||
|
||||
Reference in New Issue
Block a user