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:
Alex Grozav
2023-08-25 11:33:46 +03:00
committed by GitHub
parent c833078c87
commit ed927d34b2
89 changed files with 4164 additions and 57 deletions

View File

@@ -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);

View File

@@ -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),
};
},
},
});

View File

@@ -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)}\..*`);

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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),

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,
}),

View File

@@ -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,
},

View File

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

View File

@@ -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() {

View File

@@ -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) {