mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(editor): Add git connections settings page (no-changelog) (#36991)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -95,5 +95,6 @@ export enum VIEWS {
|
||||
MIGRATION_REPORT = 'MigrationReport',
|
||||
MIGRATION_RULE_REPORT = 'MigrationRuleReport',
|
||||
RESOLVERS = 'Resolvers',
|
||||
GIT_CONNECTIONS_SETTINGS = 'GitConnectionsSettings',
|
||||
RESOURCE_CENTER = 'ResourceCenter',
|
||||
}
|
||||
|
||||
@@ -3653,6 +3653,51 @@
|
||||
"settings.chatHub.embeddingModel.notShared": "Share the credential globally to ensure all users can use file knowledge in their personal agents.",
|
||||
"settings.chatHub.label.provider": "Provider",
|
||||
"settings.chatHub.label.credential": "Credential",
|
||||
"settings.gitConnections.title": "Environments v2",
|
||||
"settings.gitConnections.description": "Connect this instance to another environment so your workflows can be promoted.",
|
||||
"settings.gitConnections.addConnector": "Add connector",
|
||||
"settings.gitConnections.addConnector.description": "Connect a git repository to this instance.",
|
||||
"settings.gitConnections.connectors.title": "Connectors",
|
||||
"settings.gitConnections.connectors.description": "Add a connector to link this instance with the environment you promote to.",
|
||||
"settings.gitConnections.connectorRow.provider": "Git provider",
|
||||
"settings.gitConnections.scope.instance": "Instance",
|
||||
"settings.gitConnections.connectorType.git": "Git",
|
||||
"settings.gitConnections.connectorType.git.description": "Sync with a git repository over SSH or HTTPS.",
|
||||
"settings.gitConnections.error.title": "Couldn't load connectors",
|
||||
"settings.gitConnections.error.description": "Check your connection and try again.",
|
||||
"settings.gitConnections.dialog.title.selectType": "Add connector",
|
||||
"settings.gitConnections.dialog.selectType.description": "Choose how this instance connects to another environment.",
|
||||
"settings.gitConnections.dialog.selectType.ariaDescription": "Choose the type of connector to add.",
|
||||
"settings.gitConnections.dialog.title.deployKey": "Add the deploy key",
|
||||
"settings.gitConnections.dialog.deployKey.ariaDescription": "Copy the deploy key and add it to your git provider.",
|
||||
"settings.gitConnections.dialog.title.create": "Add git connector",
|
||||
"settings.gitConnections.dialog.title.edit": "Edit git connector",
|
||||
"settings.gitConnections.dialog.ariaDescription": "Configure the git repository this connector pushes to.",
|
||||
"settings.gitConnections.form.name": "Name",
|
||||
"settings.gitConnections.form.repositoryUrl": "Repository URL",
|
||||
"settings.gitConnections.form.branchName": "Branch",
|
||||
"settings.gitConnections.form.connectionType": "Connection type",
|
||||
"settings.gitConnections.form.keyType": "SSH key type",
|
||||
"settings.gitConnections.form.username": "Username",
|
||||
"settings.gitConnections.form.password": "Password or access token",
|
||||
"settings.gitConnections.form.credentials.keepPlaceholder": "Leave blank to keep the current credentials",
|
||||
"settings.gitConnections.form.incomplete": "Enter a name and a repository URL.",
|
||||
"settings.gitConnections.form.noChanges": "Nothing to save yet.",
|
||||
"settings.gitConnections.form.credentials.pairOnly": "Username and password can only be changed together. Enter both to update either one.",
|
||||
"settings.gitConnections.form.credentials.required": "Enter both a username and a password to connect over HTTPS.",
|
||||
"settings.gitConnections.publicKey.label": "Deploy key",
|
||||
"settings.gitConnections.publicKey.copy": "Copy deploy key",
|
||||
"settings.gitConnections.publicKey.done": "Done",
|
||||
"settings.gitConnections.publicKey.hint": "Add this key as a deploy key with write access in your git provider, then the connection is ready to use.",
|
||||
"settings.gitConnections.delete.confirm.title": "Are you sure?",
|
||||
"settings.gitConnections.delete.confirm.message": "This can't be undone.",
|
||||
"settings.gitConnections.delete.confirm.button": "Delete",
|
||||
"settings.gitConnections.toast.created": "Git connector created",
|
||||
"settings.gitConnections.toast.updated": "Git connector updated",
|
||||
"settings.gitConnections.toast.deleted": "Connector deleted",
|
||||
"settings.gitConnections.toast.error.load": "Couldn't load the git connector",
|
||||
"settings.gitConnections.toast.error.save": "Couldn't save the git connector",
|
||||
"settings.gitConnections.toast.error.delete": "Couldn't delete the connector",
|
||||
"settings.goBack": "Go back",
|
||||
"settings.personal": "Personal",
|
||||
"settings.personal.basicInformation": "Basic Information",
|
||||
|
||||
@@ -2,3 +2,7 @@ export interface IRestApiContext {
|
||||
baseUrl: string;
|
||||
pushRef: string;
|
||||
}
|
||||
|
||||
export interface PublicApiContext {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PublicApiContext } from '@n8n/rest-api-client';
|
||||
import { randomString, setGlobalState } from 'n8n-workflow';
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
@@ -129,7 +130,7 @@ export const useRootStore = defineStore(STORES.ROOT, () => {
|
||||
pushRef: state.value.pushRef,
|
||||
}));
|
||||
|
||||
const publicApiContext = computed(() => ({
|
||||
const publicApiContext = computed<PublicApiContext>(() => ({
|
||||
baseUrl: `${state.value.baseUrl}${state.value.publicApiPath}`,
|
||||
}));
|
||||
|
||||
|
||||
@@ -119,6 +119,14 @@ export function useSettingsItems() {
|
||||
available: canUserAccessRouteByName(VIEWS.SOURCE_CONTROL),
|
||||
route: { to: { name: VIEWS.SOURCE_CONTROL } },
|
||||
},
|
||||
{
|
||||
id: 'settings-git-connections',
|
||||
icon: 'git-branch',
|
||||
label: i18n.baseText('settings.gitConnections.title'),
|
||||
position: 'top',
|
||||
available: canUserAccessRouteByName(VIEWS.GIT_CONNECTIONS_SETTINGS),
|
||||
route: { to: { name: VIEWS.GIT_CONNECTIONS_SETTINGS } },
|
||||
},
|
||||
{
|
||||
id: 'settings-sso',
|
||||
icon: 'user-lock',
|
||||
|
||||
@@ -199,6 +199,38 @@ describe('router', () => {
|
||||
20000,
|
||||
);
|
||||
|
||||
const gitConnectionScopes: Scope[] = [
|
||||
'gitConnection:list',
|
||||
'gitConnection:read',
|
||||
'gitConnection:create',
|
||||
'gitConnection:update',
|
||||
'gitConnection:delete',
|
||||
];
|
||||
|
||||
test.each<[string, RouteRecordName, Scope[], boolean, boolean]>([
|
||||
['/settings/git-connections', VIEWS.WORKFLOWS, [], true, true],
|
||||
['/settings/git-connections', VIEWS.WORKFLOWS, ['gitConnection:list'], true, true],
|
||||
['/settings/git-connections', VIEWS.GIT_CONNECTIONS_SETTINGS, gitConnectionScopes, true, true],
|
||||
['/settings/git-connections', VIEWS.WORKFLOWS, gitConnectionScopes, false, true],
|
||||
['/settings/git-connections', VIEWS.WORKFLOWS, gitConnectionScopes, true, false],
|
||||
])(
|
||||
'should resolve %s to %s with %s permissions, module active %s and flag on %s (git connections)',
|
||||
async (path, name, scopes, isModuleActive, isFlagOn) => {
|
||||
const rbacStore = useRBACStore();
|
||||
|
||||
settingsStore.settings.activeModules = isModuleActive ? ['git-connections'] : [];
|
||||
settingsStore.settings.envFeatureFlags = {
|
||||
N8N_ENV_FEAT_PROMOTIONS: isFlagOn ? 'true' : 'false',
|
||||
} as typeof settingsStore.settings.envFeatureFlags;
|
||||
rbacStore.setGlobalScopes(scopes);
|
||||
|
||||
await router.push(path);
|
||||
expect(initializeAuthenticatedFeaturesSpy).toHaveBeenCalled();
|
||||
expect(router.currentRoute.value.name).toBe(name);
|
||||
},
|
||||
20000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
[VIEWS.PERSONAL_SETTINGS, true],
|
||||
[VIEWS.USAGE, false],
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useRecentResources } from '@/features/shared/commandBar/composables/use
|
||||
import { usePostHog } from '@/app/stores/posthog.store';
|
||||
import { RESOURCE_CENTER_EXPERIMENT, TEMPLATE_SETUP_EXPERIENCE } from '@/app/constants/experiments';
|
||||
import { useDynamicCredentials } from '@/features/resolvers/composables/useDynamicCredentials';
|
||||
import { usePromotionsEnabled } from '@/features/shared/promotions/usePromotionsEnabled';
|
||||
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
|
||||
import { INSTANCE_AI_VIEW } from '@/features/ai/instanceAi/constants';
|
||||
import {
|
||||
@@ -58,6 +59,8 @@ const SettingsPersonalView = async () =>
|
||||
const SettingsUsersView = async () =>
|
||||
await import('@/features/settings/users/views/SettingsUsersView.vue');
|
||||
const SettingsResolversView = async () => await import('@/features/resolvers/ResolversView.vue');
|
||||
const GitConnectionsView = async () =>
|
||||
await import('@/features/integrations/gitConnections.ee/views/GitConnectionsView.vue');
|
||||
const SettingsCommunityNodesView = async () =>
|
||||
await import('@/features/settings/communityNodes/views/SettingsCommunityNodesView.vue');
|
||||
const SettingsApiView = async () =>
|
||||
@@ -983,6 +986,38 @@ export const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'git-connections',
|
||||
name: VIEWS.GIT_CONNECTIONS_SETTINGS,
|
||||
component: GitConnectionsView,
|
||||
meta: {
|
||||
middleware: ['authenticated', 'rbac', 'custom'],
|
||||
middlewareOptions: {
|
||||
rbac: {
|
||||
scope: [
|
||||
'gitConnection:list',
|
||||
'gitConnection:read',
|
||||
'gitConnection:create',
|
||||
'gitConnection:update',
|
||||
'gitConnection:delete',
|
||||
],
|
||||
options: { mode: 'allOf' },
|
||||
},
|
||||
custom: () => {
|
||||
const { isEnabled } = usePromotionsEnabled();
|
||||
return isEnabled.value;
|
||||
},
|
||||
},
|
||||
telemetry: {
|
||||
pageCategory: 'settings',
|
||||
getProperties() {
|
||||
return {
|
||||
feature: 'git-connections',
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'external-secrets',
|
||||
name: VIEWS.EXTERNAL_SECRETS_SETTINGS,
|
||||
|
||||
@@ -68,6 +68,21 @@ describe('handleSessionExpired', () => {
|
||||
expect(hrefSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs out when the public API rejects the session', async () => {
|
||||
const logout = vi.fn().mockResolvedValue({ redirectUrl: null });
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
currentUser: { id: '123' },
|
||||
logout,
|
||||
} as unknown as ReturnType<typeof useUsersStore>);
|
||||
const router = createRouterMock();
|
||||
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
|
||||
|
||||
await handleSessionExpired(router, '/api/v1');
|
||||
|
||||
expect(logout).toHaveBeenCalledTimes(1);
|
||||
expect(hrefSpy).toHaveBeenCalledWith(SIGNIN_HREF);
|
||||
});
|
||||
|
||||
it('logs out, skips the unsaved-changes prompt, and reloads to signin with the current path when a current user is present', async () => {
|
||||
const logout = vi.fn().mockResolvedValue({ redirectUrl: null });
|
||||
vi.mocked(useUsersStore).mockReturnValue({
|
||||
|
||||
@@ -12,11 +12,12 @@ import { getSanitizedCurrentPath } from '@/app/utils/urlUtils';
|
||||
export async function handleSessionExpired(router: Router, baseURL: string): Promise<void> {
|
||||
const usersStore = useUsersStore();
|
||||
const sessionExpiryStore = useSessionExpiryStore();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
if (
|
||||
sessionExpiryStore.handled ||
|
||||
!usersStore.currentUser ||
|
||||
baseURL !== useRootStore().restApiContext.baseUrl
|
||||
(baseURL !== rootStore.restApiContext.baseUrl && baseURL !== rootStore.publicApiContext.baseUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCard,
|
||||
N8nCopyInput,
|
||||
N8nDialog,
|
||||
N8nDialogFooter,
|
||||
N8nDialogHeader,
|
||||
N8nDialogTitle,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nInputLabel,
|
||||
N8nOption,
|
||||
N8nSelect,
|
||||
N8nText,
|
||||
N8nTooltip,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { computed, nextTick, onMounted, reactive, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import {
|
||||
createGitConnection,
|
||||
fetchGitConnection,
|
||||
updateGitConnection,
|
||||
type GitConnection,
|
||||
} from '../gitConnections.api';
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildUpdatePayload,
|
||||
type GitConnectionFormState,
|
||||
} from '../gitConnections.utils';
|
||||
|
||||
const CREDENTIALS_HINT_ID = 'git-connection-credentials-hint';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
connectionId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
saved: [id: string];
|
||||
delete: [id: string];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const form = reactive<GitConnectionFormState>({
|
||||
name: '',
|
||||
repositoryUrl: '',
|
||||
branchName: '',
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'ed25519',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const step = ref<'type' | 'form' | 'key'>(props.connectionId === undefined ? 'type' : 'form');
|
||||
const current = ref<GitConnection | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const newPublicKey = ref<string | null>(null);
|
||||
const nameInput = useTemplateRef<InstanceType<typeof N8nInput>>('nameInput');
|
||||
const typeCard = useTemplateRef<{ $el?: HTMLElement }>('typeCard');
|
||||
const doneButton = useTemplateRef<{ $el?: HTMLElement }>('doneButton');
|
||||
|
||||
const isEdit = computed(() => props.connectionId !== undefined);
|
||||
|
||||
const title = computed(() => {
|
||||
if (step.value === 'type')
|
||||
return i18n.baseText('settings.gitConnections.dialog.title.selectType');
|
||||
if (step.value === 'key') return i18n.baseText('settings.gitConnections.dialog.title.deployKey');
|
||||
return i18n.baseText(
|
||||
isEdit.value
|
||||
? 'settings.gitConnections.dialog.title.edit'
|
||||
: 'settings.gitConnections.dialog.title.create',
|
||||
);
|
||||
});
|
||||
|
||||
const ariaDescription = computed(() => {
|
||||
if (step.value === 'type')
|
||||
return i18n.baseText('settings.gitConnections.dialog.selectType.ariaDescription');
|
||||
if (step.value === 'key')
|
||||
return i18n.baseText('settings.gitConnections.dialog.deployKey.ariaDescription');
|
||||
return i18n.baseText('settings.gitConnections.dialog.ariaDescription');
|
||||
});
|
||||
|
||||
const credentialsRequired = computed(
|
||||
() => form.connectionType === 'https' && current.value?.connectionType !== 'https',
|
||||
);
|
||||
|
||||
const hasUsername = computed(() => form.username.trim().length > 0);
|
||||
const hasPassword = computed(() => form.password.trim().length > 0);
|
||||
|
||||
// `buildUpdatePayload` sends username and password together or not at all, so a
|
||||
// password-only edit would silently do nothing.
|
||||
const areCredentialsIncomplete = computed(
|
||||
() =>
|
||||
form.connectionType === 'https' &&
|
||||
(credentialsRequired.value || !!form.username || !!form.password) &&
|
||||
!(hasUsername.value && hasPassword.value),
|
||||
);
|
||||
|
||||
const isKeyTypeDisabled = computed(() => current.value?.connectionType === 'ssh');
|
||||
|
||||
const existingPublicKey = computed(() =>
|
||||
form.connectionType === 'ssh' && current.value?.connectionType === 'ssh'
|
||||
? current.value.publicKey
|
||||
: null,
|
||||
);
|
||||
|
||||
// The payload builder already decides what an edit would actually send, so an
|
||||
// empty one means there is nothing to save.
|
||||
const hasChanges = computed(
|
||||
() => !current.value || Object.keys(buildUpdatePayload(form, current.value)).length > 0,
|
||||
);
|
||||
|
||||
// Setting credentials for the first time and rotating existing ones fail for
|
||||
// different reasons, and the rotation rule is the one that is not obvious.
|
||||
const credentialsMessage = computed(() =>
|
||||
i18n.baseText(
|
||||
credentialsRequired.value
|
||||
? 'settings.gitConnections.form.credentials.required'
|
||||
: 'settings.gitConnections.form.credentials.pairOnly',
|
||||
),
|
||||
);
|
||||
|
||||
// A half-filled credential pair blocks the whole form, which is otherwise
|
||||
// invisible when the user is editing an unrelated field.
|
||||
const saveDisabledReason = computed(() => {
|
||||
if (!form.name.trim() || !form.repositoryUrl.trim())
|
||||
return i18n.baseText('settings.gitConnections.form.incomplete');
|
||||
if (areCredentialsIncomplete.value) return credentialsMessage.value;
|
||||
if (!hasChanges.value) return i18n.baseText('settings.gitConnections.form.noChanges');
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const isSaveDisabled = computed(
|
||||
() => isSubmitting.value || isLoading.value || saveDisabledReason.value !== undefined,
|
||||
);
|
||||
|
||||
// The dialog is rendered under `v-if`, so it mounts already open: a watcher on
|
||||
// `open` would never fire and the edit form would stay blank.
|
||||
onMounted(async () => {
|
||||
if (props.connectionId === undefined) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const connection = await fetchGitConnection(rootStore.publicApiContext, props.connectionId);
|
||||
current.value = connection;
|
||||
form.name = connection.name;
|
||||
form.repositoryUrl = connection.repositoryUrl;
|
||||
form.branchName = connection.branchName ?? '';
|
||||
form.connectionType = connection.connectionType;
|
||||
form.keyGeneratorType = connection.keyGeneratorType ?? 'ed25519';
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.gitConnections.toast.error.load'));
|
||||
close();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
focusStep();
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function onOpenChange(value: boolean) {
|
||||
if (value || isSubmitting.value) return;
|
||||
close();
|
||||
}
|
||||
|
||||
function focusStep() {
|
||||
void nextTick(() => {
|
||||
if (step.value === 'type') typeCard.value?.$el?.focus();
|
||||
else if (step.value === 'key') doneButton.value?.$el?.focus();
|
||||
else nameInput.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onOpenAutoFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
focusStep();
|
||||
}
|
||||
|
||||
// The parent restores focus once the refreshed list has rendered; reka's own
|
||||
// restore runs in a later macrotask and would overwrite it.
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function selectGit() {
|
||||
step.value = 'form';
|
||||
focusStep();
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (isSaveDisabled.value) return;
|
||||
|
||||
const existing = current.value;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
let saved: GitConnection;
|
||||
if (existing) {
|
||||
const payload = buildUpdatePayload(form, existing);
|
||||
saved = await updateGitConnection(rootStore.publicApiContext, existing.id, payload);
|
||||
} else {
|
||||
saved = await createGitConnection(rootStore.publicApiContext, buildCreatePayload(form));
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText(
|
||||
existing
|
||||
? 'settings.gitConnections.toast.updated'
|
||||
: 'settings.gitConnections.toast.created',
|
||||
),
|
||||
type: 'success',
|
||||
});
|
||||
emit('saved', saved.id);
|
||||
|
||||
if (saved.publicKey && saved.publicKey !== existing?.publicKey) {
|
||||
newPublicKey.value = saved.publicKey;
|
||||
step.value = 'key';
|
||||
focusStep();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.gitConnections.toast.error.save'));
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nDialog
|
||||
:open="open"
|
||||
size="medium"
|
||||
:aria-description="ariaDescription"
|
||||
@open-auto-focus="onOpenAutoFocus"
|
||||
@close-auto-focus="onCloseAutoFocus"
|
||||
@update:open="onOpenChange"
|
||||
>
|
||||
<N8nDialogHeader>
|
||||
<N8nDialogTitle>{{ title }}</N8nDialogTitle>
|
||||
</N8nDialogHeader>
|
||||
|
||||
<div v-if="step === 'type'" :class="$style.form" data-test-id="git-connection-type-step">
|
||||
<N8nText color="text-base" size="medium">
|
||||
{{ i18n.baseText('settings.gitConnections.dialog.selectType.description') }}
|
||||
</N8nText>
|
||||
<N8nCard
|
||||
ref="typeCard"
|
||||
:class="$style.typeCard"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
data-test-id="git-connection-type-git"
|
||||
@click="selectGit"
|
||||
@keydown.enter="selectGit"
|
||||
@keydown.space.prevent="selectGit"
|
||||
>
|
||||
<template #prepend>
|
||||
<N8nIcon icon="git-branch" color="text-dark" :size="20" />
|
||||
</template>
|
||||
<template #header>
|
||||
<N8nText bold>{{ i18n.baseText('settings.gitConnections.connectorType.git') }}</N8nText>
|
||||
</template>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.gitConnections.connectorType.git.description') }}
|
||||
</N8nText>
|
||||
<template #append>
|
||||
<N8nIcon icon="chevron-right" color="text-light" />
|
||||
</template>
|
||||
</N8nCard>
|
||||
<N8nDialogFooter>
|
||||
<N8nButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
data-test-id="git-connection-type-cancel-button"
|
||||
@click="close"
|
||||
>
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
</N8nDialogFooter>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="step === 'key' && newPublicKey"
|
||||
:class="$style.form"
|
||||
data-test-id="git-connection-key-step"
|
||||
>
|
||||
<N8nInputLabel :label="i18n.baseText('settings.gitConnections.publicKey.label')">
|
||||
<N8nCopyInput
|
||||
:value="newPublicKey"
|
||||
:copy-label="i18n.baseText('settings.gitConnections.publicKey.copy')"
|
||||
:copied-label="i18n.baseText('generic.copiedToClipboard')"
|
||||
/>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{ i18n.baseText('settings.gitConnections.publicKey.hint') }}
|
||||
</N8nText>
|
||||
</N8nInputLabel>
|
||||
<N8nDialogFooter>
|
||||
<N8nButton ref="doneButton" data-test-id="git-connection-done-button" @click="close">
|
||||
{{ i18n.baseText('settings.gitConnections.publicKey.done') }}
|
||||
</N8nButton>
|
||||
</N8nDialogFooter>
|
||||
</div>
|
||||
|
||||
<form
|
||||
v-else
|
||||
:class="$style.form"
|
||||
data-test-id="git-connection-form-step"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-name"
|
||||
:label="i18n.baseText('settings.gitConnections.form.name')"
|
||||
required
|
||||
>
|
||||
<N8nInput
|
||||
id="git-connection-name"
|
||||
ref="nameInput"
|
||||
v-model="form.name"
|
||||
:disabled="isLoading"
|
||||
data-test-id="git-connection-name-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-repository-url"
|
||||
:label="i18n.baseText('settings.gitConnections.form.repositoryUrl')"
|
||||
required
|
||||
>
|
||||
<N8nInput
|
||||
id="git-connection-repository-url"
|
||||
v-model="form.repositoryUrl"
|
||||
:disabled="isLoading"
|
||||
data-test-id="git-connection-repository-url-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-branch"
|
||||
:label="i18n.baseText('settings.gitConnections.form.branchName')"
|
||||
>
|
||||
<N8nInput
|
||||
id="git-connection-branch"
|
||||
v-model="form.branchName"
|
||||
:disabled="isLoading"
|
||||
data-test-id="git-connection-branch-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-type"
|
||||
:label="i18n.baseText('settings.gitConnections.form.connectionType')"
|
||||
>
|
||||
<N8nSelect
|
||||
id="git-connection-type"
|
||||
v-model="form.connectionType"
|
||||
:teleported="false"
|
||||
:disabled="isLoading"
|
||||
data-test-id="git-connection-type-select"
|
||||
>
|
||||
<N8nOption value="ssh" label="SSH" />
|
||||
<N8nOption value="https" label="HTTPS" />
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
|
||||
<template v-if="form.connectionType === 'ssh'">
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-key-type"
|
||||
:label="i18n.baseText('settings.gitConnections.form.keyType')"
|
||||
>
|
||||
<N8nSelect
|
||||
id="git-connection-key-type"
|
||||
v-model="form.keyGeneratorType"
|
||||
:teleported="false"
|
||||
:disabled="isLoading || isKeyTypeDisabled"
|
||||
data-test-id="git-connection-key-type-select"
|
||||
>
|
||||
<N8nOption value="ed25519" label="ED25519" />
|
||||
<N8nOption value="rsa" label="RSA" />
|
||||
</N8nSelect>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
v-if="existingPublicKey"
|
||||
:label="i18n.baseText('settings.gitConnections.publicKey.label')"
|
||||
>
|
||||
<N8nCopyInput
|
||||
:value="existingPublicKey"
|
||||
:copy-label="i18n.baseText('settings.gitConnections.publicKey.copy')"
|
||||
:copied-label="i18n.baseText('generic.copiedToClipboard')"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-username"
|
||||
:label="i18n.baseText('settings.gitConnections.form.username')"
|
||||
:required="areCredentialsIncomplete"
|
||||
>
|
||||
<N8nInput
|
||||
id="git-connection-username"
|
||||
v-model="form.username"
|
||||
autocomplete="off"
|
||||
:disabled="isLoading"
|
||||
:aria-required="credentialsRequired"
|
||||
:aria-invalid="areCredentialsIncomplete"
|
||||
:aria-describedby="areCredentialsIncomplete ? CREDENTIALS_HINT_ID : undefined"
|
||||
:placeholder="
|
||||
areCredentialsIncomplete
|
||||
? ''
|
||||
: i18n.baseText('settings.gitConnections.form.credentials.keepPlaceholder')
|
||||
"
|
||||
data-test-id="git-connection-username-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nInputLabel
|
||||
input-name="git-connection-password"
|
||||
:label="i18n.baseText('settings.gitConnections.form.password')"
|
||||
:required="areCredentialsIncomplete"
|
||||
>
|
||||
<N8nInput
|
||||
id="git-connection-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:disabled="isLoading"
|
||||
:aria-required="credentialsRequired"
|
||||
:aria-invalid="areCredentialsIncomplete"
|
||||
:aria-describedby="areCredentialsIncomplete ? CREDENTIALS_HINT_ID : undefined"
|
||||
:placeholder="
|
||||
areCredentialsIncomplete
|
||||
? ''
|
||||
: i18n.baseText('settings.gitConnections.form.credentials.keepPlaceholder')
|
||||
"
|
||||
data-test-id="git-connection-password-input"
|
||||
/>
|
||||
</N8nInputLabel>
|
||||
<N8nText
|
||||
v-if="areCredentialsIncomplete"
|
||||
:id="CREDENTIALS_HINT_ID"
|
||||
size="small"
|
||||
color="danger"
|
||||
>
|
||||
{{ credentialsMessage }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<N8nDialogFooter>
|
||||
<N8nButton
|
||||
v-if="connectionId"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
:class="$style.deleteButton"
|
||||
:disabled="isSubmitting || isLoading"
|
||||
data-test-id="git-connection-delete-button"
|
||||
@click="emit('delete', connectionId)"
|
||||
>
|
||||
{{ i18n.baseText('generic.delete') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isSubmitting"
|
||||
data-test-id="git-connection-cancel-button"
|
||||
@click="close"
|
||||
>
|
||||
{{ i18n.baseText('generic.cancel') }}
|
||||
</N8nButton>
|
||||
<N8nTooltip :disabled="!saveDisabledReason" :content="saveDisabledReason">
|
||||
<N8nButton
|
||||
type="submit"
|
||||
:loading="isSubmitting"
|
||||
:disabled="isSaveDisabled"
|
||||
data-test-id="git-connection-save-button"
|
||||
>
|
||||
{{ i18n.baseText('generic.save') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</N8nDialogFooter>
|
||||
</form>
|
||||
</N8nDialog>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// Not `hoverable`: that turns the border primary-orange on hover and focus.
|
||||
.typeCard {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--background--hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: var(--focus--border-width) solid var(--focus--outline-color);
|
||||
}
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
margin-top: var(--spacing--xs);
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import type {
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionListPublicDto,
|
||||
GitConnectionPublicDto,
|
||||
UpdateGitConnectionDto,
|
||||
} from '@n8n/api-types';
|
||||
import type { PublicApiContext } from '@n8n/rest-api-client';
|
||||
import { request } from '@n8n/rest-api-client';
|
||||
|
||||
export type GitConnection = GitConnectionPublicDto;
|
||||
export type GitConnectionSummary = GitConnectionListPublicDto['data'][number];
|
||||
|
||||
const gitConnectionsApiRoot = '/git-connections';
|
||||
|
||||
// The backend accepts a single connection, so the first page is the whole list.
|
||||
// Revisit when project-level connections land.
|
||||
export const fetchGitConnections = async (
|
||||
context: PublicApiContext,
|
||||
): Promise<GitConnectionSummary[]> => {
|
||||
const response: GitConnectionListPublicDto = await request({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: gitConnectionsApiRoot,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchGitConnection = async (
|
||||
context: PublicApiContext,
|
||||
id: string,
|
||||
): Promise<GitConnection> =>
|
||||
await request({
|
||||
method: 'GET',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${gitConnectionsApiRoot}/${id}`,
|
||||
});
|
||||
|
||||
export const createGitConnection = async (
|
||||
context: PublicApiContext,
|
||||
payload: CreateGitConnectionDto,
|
||||
): Promise<GitConnection> =>
|
||||
await request({
|
||||
method: 'POST',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: gitConnectionsApiRoot,
|
||||
data: payload,
|
||||
});
|
||||
|
||||
export const updateGitConnection = async (
|
||||
context: PublicApiContext,
|
||||
id: string,
|
||||
payload: UpdateGitConnectionDto,
|
||||
): Promise<GitConnection> =>
|
||||
await request({
|
||||
method: 'PUT',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${gitConnectionsApiRoot}/${id}`,
|
||||
data: payload,
|
||||
});
|
||||
|
||||
export const deleteGitConnection = async (context: PublicApiContext, id: string): Promise<void> => {
|
||||
await request({
|
||||
method: 'DELETE',
|
||||
baseURL: context.baseUrl,
|
||||
endpoint: `${gitConnectionsApiRoot}/${id}`,
|
||||
});
|
||||
};
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import type { GitConnection } from './gitConnections.api';
|
||||
import {
|
||||
buildCreatePayload,
|
||||
buildUpdatePayload,
|
||||
type GitConnectionFormState,
|
||||
} from './gitConnections.utils';
|
||||
|
||||
const form = (overrides: Partial<GitConnectionFormState> = {}): GitConnectionFormState => ({
|
||||
name: 'Production',
|
||||
repositoryUrl: 'git@github.com:acme/workflows.git',
|
||||
branchName: '',
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'ed25519',
|
||||
username: '',
|
||||
password: '',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const existing = (overrides: Partial<GitConnection> = {}): GitConnection => ({
|
||||
id: 'conn-1',
|
||||
name: 'Production',
|
||||
repositoryUrl: 'git@github.com:acme/workflows.git',
|
||||
branchName: 'main',
|
||||
connectionType: 'ssh',
|
||||
publicKey: 'ssh-ed25519 AAAA',
|
||||
keyGeneratorType: 'ed25519',
|
||||
baseCommit: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('buildCreatePayload', () => {
|
||||
it('sends the key type and no credentials for a new ssh connection', () => {
|
||||
expect(buildCreatePayload(form({ keyGeneratorType: 'rsa' }))).toEqual({
|
||||
name: 'Production',
|
||||
repositoryUrl: 'git@github.com:acme/workflows.git',
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'rsa',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims the username but preserves the password for a new https connection', () => {
|
||||
expect(
|
||||
buildCreatePayload(
|
||||
form({
|
||||
connectionType: 'https',
|
||||
repositoryUrl: 'https://github.com/acme/workflows.git',
|
||||
username: ' deploy-bot ',
|
||||
password: ' token-123 ',
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
name: 'Production',
|
||||
repositoryUrl: 'https://github.com/acme/workflows.git',
|
||||
connectionType: 'https',
|
||||
username: 'deploy-bot',
|
||||
password: ' token-123 ',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves out an empty branch and trims the one that was typed', () => {
|
||||
expect(buildCreatePayload(form({ branchName: ' ' }))).not.toHaveProperty('branchName');
|
||||
expect(buildCreatePayload(form({ branchName: ' main ' }))).toMatchObject({
|
||||
branchName: 'main',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUpdatePayload', () => {
|
||||
it('sends nothing when nothing was changed', () => {
|
||||
expect(buildUpdatePayload(form({ branchName: 'main' }), existing())).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps the key type out of an ssh connection that stays ssh', () => {
|
||||
expect(
|
||||
buildUpdatePayload(
|
||||
form({ name: 'Staging', keyGeneratorType: 'rsa' }),
|
||||
existing({ keyGeneratorType: 'ed25519' }),
|
||||
),
|
||||
).toEqual({ name: 'Staging' });
|
||||
});
|
||||
|
||||
it('sends the key type when switching an https connection to ssh', () => {
|
||||
expect(
|
||||
buildUpdatePayload(
|
||||
form({ connectionType: 'ssh', keyGeneratorType: 'rsa' }),
|
||||
existing({ connectionType: 'https', keyGeneratorType: null, publicKey: null }),
|
||||
),
|
||||
).toEqual({ connectionType: 'ssh', keyGeneratorType: 'rsa' });
|
||||
});
|
||||
|
||||
it('sends no credentials when switching to https without filling them in', () => {
|
||||
const payload = buildUpdatePayload(form({ connectionType: 'https' }), existing());
|
||||
|
||||
expect(payload).toEqual({ connectionType: 'https' });
|
||||
});
|
||||
|
||||
it('trims the username but preserves the rotated password', () => {
|
||||
expect(
|
||||
buildUpdatePayload(
|
||||
form({ connectionType: 'https', username: ' deploy-bot ', password: ' new-token ' }),
|
||||
existing({ connectionType: 'https', keyGeneratorType: null, publicKey: null }),
|
||||
),
|
||||
).toEqual({ username: 'deploy-bot', password: ' new-token ' });
|
||||
});
|
||||
|
||||
it('treats a cleared branch as unchanged rather than removing it', () => {
|
||||
expect(buildUpdatePayload(form({ branchName: '' }), existing())).toEqual({});
|
||||
});
|
||||
});
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
CreateGitConnectionDto,
|
||||
GitConnectionType,
|
||||
GitKeyGeneratorType,
|
||||
UpdateGitConnectionDto,
|
||||
} from '@n8n/api-types';
|
||||
|
||||
import type { GitConnection } from './gitConnections.api';
|
||||
|
||||
export type GitConnectionFormState = {
|
||||
name: string;
|
||||
repositoryUrl: string;
|
||||
branchName: string;
|
||||
connectionType: GitConnectionType;
|
||||
keyGeneratorType: GitKeyGeneratorType;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export function buildCreatePayload(form: GitConnectionFormState): CreateGitConnectionDto {
|
||||
const payload: CreateGitConnectionDto = {
|
||||
name: form.name.trim(),
|
||||
repositoryUrl: form.repositoryUrl.trim(),
|
||||
connectionType: form.connectionType,
|
||||
};
|
||||
|
||||
const branchName = form.branchName.trim();
|
||||
if (branchName) {
|
||||
payload.branchName = branchName;
|
||||
}
|
||||
|
||||
if (form.connectionType === 'ssh') {
|
||||
payload.keyGeneratorType = form.keyGeneratorType;
|
||||
} else {
|
||||
payload.username = form.username.trim();
|
||||
payload.password = form.password;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function buildUpdatePayload(
|
||||
form: GitConnectionFormState,
|
||||
current: GitConnection,
|
||||
): UpdateGitConnectionDto {
|
||||
const payload: UpdateGitConnectionDto = {};
|
||||
|
||||
const name = form.name.trim();
|
||||
if (name !== current.name) {
|
||||
payload.name = name;
|
||||
}
|
||||
|
||||
const repositoryUrl = form.repositoryUrl.trim();
|
||||
if (repositoryUrl !== current.repositoryUrl) {
|
||||
payload.repositoryUrl = repositoryUrl;
|
||||
}
|
||||
|
||||
// The API has no way to clear a branch (`min(1)`, not nullable), so a blank
|
||||
// field means "leave as is" rather than "remove".
|
||||
const branchName = form.branchName.trim();
|
||||
if (branchName && branchName !== current.branchName) {
|
||||
payload.branchName = branchName;
|
||||
}
|
||||
|
||||
if (form.connectionType !== current.connectionType) {
|
||||
payload.connectionType = form.connectionType;
|
||||
}
|
||||
|
||||
if (form.connectionType === 'ssh') {
|
||||
// ssh -> ssh rejects a changed key type; only a switch to ssh mints a key.
|
||||
if (current.connectionType !== 'ssh') {
|
||||
payload.keyGeneratorType = form.keyGeneratorType;
|
||||
}
|
||||
} else if (form.username.trim() && form.password.trim()) {
|
||||
payload.username = form.username.trim();
|
||||
payload.password = form.password;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
+395
@@ -0,0 +1,395 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { screen, waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { MODAL_CANCEL, MODAL_CONFIRM } from '@/app/constants';
|
||||
import type { GitConnection, GitConnectionSummary } from '../gitConnections.api';
|
||||
import GitConnectionsView from './GitConnectionsView.vue';
|
||||
|
||||
const backend = vi.hoisted(() => {
|
||||
const connections: GitConnection[] = [];
|
||||
let idCounter = 0;
|
||||
|
||||
return {
|
||||
connections,
|
||||
reset() {
|
||||
connections.length = 0;
|
||||
idCounter = 0;
|
||||
},
|
||||
nextId: () => `conn-${++idCounter}`,
|
||||
};
|
||||
});
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
fetchGitConnections: vi.fn(),
|
||||
fetchGitConnection: vi.fn(),
|
||||
createGitConnection: vi.fn(),
|
||||
updateGitConnection: vi.fn(),
|
||||
deleteGitConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../gitConnections.api', () => api);
|
||||
|
||||
const mockConfirm = vi.fn();
|
||||
const mockShowError = vi.fn();
|
||||
const mockShowMessage = vi.fn();
|
||||
|
||||
vi.mock('@n8n/design-system', async () => ({
|
||||
...(await vi.importActual<object>('@n8n/design-system')),
|
||||
useMessage: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/composables/useToast', () => ({
|
||||
useToast: () => ({ showError: mockShowError, showMessage: mockShowMessage }),
|
||||
}));
|
||||
|
||||
const sshConnection = (overrides: Partial<GitConnection> = {}): GitConnection => ({
|
||||
id: 'conn-ssh',
|
||||
name: 'Production',
|
||||
repositoryUrl: 'git@github.com:acme/workflows.git',
|
||||
branchName: 'main',
|
||||
connectionType: 'ssh',
|
||||
publicKey: 'ssh-ed25519 EXISTING-KEY',
|
||||
keyGeneratorType: 'ed25519',
|
||||
baseCommit: null,
|
||||
createdAt: '2026-08-01T00:00:00.000Z',
|
||||
updatedAt: '2026-08-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderView = createComponentRenderer(GitConnectionsView);
|
||||
|
||||
const openAddDialog = async () => {
|
||||
await userEvent.click(screen.getByTestId('git-connections-add'));
|
||||
await userEvent.click(await screen.findByTestId('git-connection-type-git'));
|
||||
return await screen.findByTestId('git-connection-form-step');
|
||||
};
|
||||
|
||||
const openEditDialog = async (row: HTMLElement) => {
|
||||
await userEvent.click(row);
|
||||
return await screen.findByTestId('git-connection-form-step');
|
||||
};
|
||||
|
||||
const selectOption = async (select: HTMLElement, label: string) => {
|
||||
await userEvent.click(within(select).getByRole('combobox'));
|
||||
await userEvent.click(await within(select).findByText(label));
|
||||
};
|
||||
|
||||
describe('GitConnectionsView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
backend.reset();
|
||||
createTestingPinia();
|
||||
|
||||
api.fetchGitConnections.mockImplementation(async () =>
|
||||
backend.connections.map(({ publicKey: _publicKey, ...summary }) => summary),
|
||||
);
|
||||
api.fetchGitConnection.mockImplementation(
|
||||
async (_ctx: unknown, id: string) => backend.connections.find((c) => c.id === id)!,
|
||||
);
|
||||
api.createGitConnection.mockImplementation(async (_ctx: unknown, payload: GitConnection) => {
|
||||
const created: GitConnection = {
|
||||
...sshConnection(),
|
||||
...payload,
|
||||
id: backend.nextId(),
|
||||
branchName: payload.branchName ?? null,
|
||||
publicKey: payload.connectionType === 'ssh' ? 'ssh-ed25519 NEW-KEY' : null,
|
||||
};
|
||||
backend.connections.push(created);
|
||||
return created;
|
||||
});
|
||||
api.updateGitConnection.mockImplementation(
|
||||
async (_ctx: unknown, id: string, payload: Partial<GitConnection>) => {
|
||||
const index = backend.connections.findIndex((c) => c.id === id);
|
||||
const updated: GitConnection = { ...backend.connections[index], ...payload };
|
||||
if (payload.connectionType === 'ssh') {
|
||||
updated.publicKey = 'ssh-ed25519 GENERATED-KEY';
|
||||
}
|
||||
backend.connections[index] = updated;
|
||||
return updated;
|
||||
},
|
||||
);
|
||||
api.deleteGitConnection.mockImplementation(async (_ctx: unknown, id: string) => {
|
||||
backend.connections.splice(
|
||||
backend.connections.findIndex((c) => c.id === id),
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets the user add a git connector, shows its deploy key, and lists it', async () => {
|
||||
renderView();
|
||||
await screen.findByTestId('git-connections-add');
|
||||
|
||||
const dialog = await openAddDialog();
|
||||
await userEvent.type(within(dialog).getByTestId('git-connection-name-input'), 'Production');
|
||||
await userEvent.type(
|
||||
within(dialog).getByTestId('git-connection-repository-url-input'),
|
||||
'git@github.com:acme/workflows.git',
|
||||
);
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
|
||||
expect(api.createGitConnection).toHaveBeenCalledWith(expect.anything(), {
|
||||
name: 'Production',
|
||||
repositoryUrl: 'git@github.com:acme/workflows.git',
|
||||
connectionType: 'ssh',
|
||||
keyGeneratorType: 'ed25519',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(await screen.findByTestId('git-connection-key-step')).getByRole('textbox'),
|
||||
).toHaveValue('ssh-ed25519 NEW-KEY');
|
||||
|
||||
await userEvent.click(screen.getByTestId('git-connection-done-button'));
|
||||
|
||||
const card = await screen.findByTestId('git-connection-row');
|
||||
expect(card).toHaveTextContent('Production');
|
||||
expect(card).toHaveTextContent('git@github.com:acme/workflows.git');
|
||||
expect(card).toHaveTextContent('Git');
|
||||
expect(card).toHaveTextContent('Instance');
|
||||
});
|
||||
|
||||
it('lets the user rename an ssh connector without generating a new deploy key', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
expect(within(dialog).getByTestId('git-connection-repository-url-input')).toHaveValue(
|
||||
'git@github.com:acme/workflows.git',
|
||||
);
|
||||
expect(
|
||||
within(within(dialog).getByTestId('git-connection-key-type-select')).getByRole('combobox'),
|
||||
).toBeDisabled();
|
||||
|
||||
await userEvent.clear(within(dialog).getByTestId('git-connection-name-input'));
|
||||
await userEvent.type(within(dialog).getByTestId('git-connection-name-input'), 'Staging');
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
|
||||
expect(api.updateGitConnection).toHaveBeenCalledWith(expect.anything(), 'conn-ssh', {
|
||||
name: 'Staging',
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('git-connection-form-step')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.queryByTestId('git-connection-key-step')).not.toBeInTheDocument();
|
||||
expect(await screen.findByTestId('git-connection-row')).toHaveTextContent('Staging');
|
||||
});
|
||||
|
||||
it('shows the new deploy key when a connector is switched from https to ssh', async () => {
|
||||
backend.connections.push(
|
||||
sshConnection({
|
||||
id: 'conn-https',
|
||||
connectionType: 'https',
|
||||
repositoryUrl: 'https://github.com/acme/workflows.git',
|
||||
publicKey: null,
|
||||
keyGeneratorType: null,
|
||||
}),
|
||||
);
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
await selectOption(within(dialog).getByTestId('git-connection-type-select'), 'SSH');
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
|
||||
expect(
|
||||
within(await screen.findByTestId('git-connection-key-step')).getByRole('textbox'),
|
||||
).toHaveValue('ssh-ed25519 GENERATED-KEY');
|
||||
});
|
||||
|
||||
it('will not save a switch to https until both credentials are given', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
await selectOption(within(dialog).getByTestId('git-connection-type-select'), 'HTTPS');
|
||||
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
expect(api.updateGitConnection).not.toHaveBeenCalled();
|
||||
|
||||
const usernameInput = within(dialog).getByTestId('git-connection-username-input');
|
||||
const passwordInput = within(dialog).getByTestId('git-connection-password-input');
|
||||
await userEvent.type(usernameInput, ' ');
|
||||
await userEvent.type(passwordInput, ' ');
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeDisabled();
|
||||
|
||||
await userEvent.clear(usernameInput);
|
||||
await userEvent.clear(passwordInput);
|
||||
await userEvent.type(usernameInput, 'deploy-bot');
|
||||
await userEvent.type(passwordInput, 'token');
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
|
||||
expect(api.updateGitConnection).toHaveBeenCalledWith(expect.anything(), 'conn-ssh', {
|
||||
connectionType: 'https',
|
||||
username: 'deploy-bot',
|
||||
password: 'token',
|
||||
});
|
||||
});
|
||||
|
||||
it('will not save an https connector when only the password was retyped', async () => {
|
||||
backend.connections.push(
|
||||
sshConnection({
|
||||
id: 'conn-https',
|
||||
connectionType: 'https',
|
||||
repositoryUrl: 'https://github.com/acme/workflows.git',
|
||||
publicKey: null,
|
||||
keyGeneratorType: null,
|
||||
}),
|
||||
);
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
const usernameInput = within(dialog).getByTestId('git-connection-username-input');
|
||||
const passwordInput = within(dialog).getByTestId('git-connection-password-input');
|
||||
await userEvent.type(usernameInput, ' ');
|
||||
await userEvent.type(passwordInput, ' ');
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeDisabled();
|
||||
|
||||
await userEvent.clear(usernameInput);
|
||||
await userEvent.clear(passwordInput);
|
||||
await userEvent.type(passwordInput, 'new-token');
|
||||
|
||||
// Editing an unrelated field must not let the half-filled pair through.
|
||||
await userEvent.type(within(dialog).getByTestId('git-connection-name-input'), ' renamed');
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeDisabled();
|
||||
|
||||
await userEvent.type(usernameInput, 'deploy-bot');
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('offers no save until something in the connector is changed', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeDisabled();
|
||||
|
||||
await userEvent.type(within(dialog).getByTestId('git-connection-name-input'), '!');
|
||||
expect(within(dialog).getByTestId('git-connection-save-button')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('reports the problem and closes when the connector cannot be opened', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
api.fetchGitConnection.mockRejectedValueOnce(new Error('Connection not found'));
|
||||
renderView();
|
||||
|
||||
await userEvent.click(await screen.findByTestId('git-connection-row'));
|
||||
|
||||
await waitFor(() => expect(mockShowError).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('git-connection-form-step')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns focus to the connector when its dialog is closed', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
renderView();
|
||||
|
||||
const row = await screen.findByTestId('git-connection-row');
|
||||
const dialog = await openEditDialog(row);
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production'),
|
||||
);
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-cancel-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId('git-connection-form-step')).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(row).toHaveFocus();
|
||||
});
|
||||
|
||||
it('will not let a second connector be added until the first one is deleted', async () => {
|
||||
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
|
||||
backend.connections.push(sshConnection());
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
expect(screen.queryByTestId('git-connections-add')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-delete-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('git-connections-add')).toHaveAttribute('role', 'button'),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the entered values and reports the error when saving fails', async () => {
|
||||
const serverError = new Error('Repository URL is invalid');
|
||||
api.createGitConnection.mockRejectedValueOnce(serverError);
|
||||
renderView();
|
||||
await screen.findByTestId('git-connections-add');
|
||||
|
||||
const dialog = await openAddDialog();
|
||||
await userEvent.type(within(dialog).getByTestId('git-connection-name-input'), 'Production');
|
||||
await userEvent.type(
|
||||
within(dialog).getByTestId('git-connection-repository-url-input'),
|
||||
'not-a-url',
|
||||
);
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-save-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockShowError).toHaveBeenCalledWith(serverError, expect.any(String)),
|
||||
);
|
||||
expect(screen.getByTestId('git-connection-form-step')).toBeInTheDocument();
|
||||
expect(within(dialog).getByTestId('git-connection-name-input')).toHaveValue('Production');
|
||||
expect(screen.queryByTestId('git-connection-row')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a connector once the deletion is confirmed', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
mockConfirm.mockResolvedValue(MODAL_CONFIRM);
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-delete-button'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.deleteGitConnection).toHaveBeenCalledWith(expect.anything(), 'conn-ssh'),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByTestId('git-connection-row')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('keeps the connector when the deletion is cancelled', async () => {
|
||||
backend.connections.push(sshConnection());
|
||||
mockConfirm.mockResolvedValue(MODAL_CANCEL);
|
||||
renderView();
|
||||
|
||||
const dialog = await openEditDialog(await screen.findByTestId('git-connection-row'));
|
||||
await userEvent.click(within(dialog).getByTestId('git-connection-delete-button'));
|
||||
|
||||
expect(api.deleteGitConnection).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('git-connection-row')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers a retry instead of the empty state when the list cannot be loaded', async () => {
|
||||
const retry = Promise.withResolvers<GitConnectionSummary[]>();
|
||||
api.fetchGitConnections
|
||||
.mockRejectedValueOnce(new Error('Request failed'))
|
||||
.mockImplementationOnce(async () => await retry.promise);
|
||||
renderView();
|
||||
|
||||
const errorState = await screen.findByTestId('git-connections-load-error');
|
||||
expect(errorState).toHaveTextContent("Couldn't load connectors");
|
||||
expect(screen.queryByTestId('git-connections-add')).not.toBeInTheDocument();
|
||||
|
||||
backend.connections.push(sshConnection());
|
||||
await userEvent.click(within(errorState).getByRole('button'));
|
||||
expect(screen.queryByTestId('git-connections-add')).not.toBeInTheDocument();
|
||||
|
||||
retry.resolve(backend.connections);
|
||||
expect(await screen.findByTestId('git-connection-row')).toHaveTextContent('Production');
|
||||
});
|
||||
});
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import {
|
||||
N8nEmptyState,
|
||||
N8nIcon,
|
||||
N8nLoading2,
|
||||
N8nSettingsLayout,
|
||||
N8nSettingsPageHeader,
|
||||
N8nSettingsRow,
|
||||
N8nSettingsRowConfigure,
|
||||
N8nSettingsRowGroup,
|
||||
N8nSettingsSection,
|
||||
useMessage,
|
||||
} from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
|
||||
import { MODAL_CONFIRM } from '@/app/constants';
|
||||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import GitConnectionDialog from '../components/GitConnectionDialog.vue';
|
||||
import {
|
||||
deleteGitConnection,
|
||||
fetchGitConnections,
|
||||
type GitConnectionSummary,
|
||||
} from '../gitConnections.api';
|
||||
|
||||
// Placeholder until the feature has a documentation page.
|
||||
const DOCS_URL = '#';
|
||||
|
||||
const i18n = useI18n();
|
||||
const toast = useToast();
|
||||
const message = useMessage();
|
||||
const rootStore = useRootStore();
|
||||
const documentTitle = useDocumentTitle();
|
||||
|
||||
const connections = ref<GitConnectionSummary[]>([]);
|
||||
const isInitialLoading = ref(true);
|
||||
const isFetching = ref(false);
|
||||
const loadError = ref(false);
|
||||
const dialogOpen = ref(false);
|
||||
const editingId = ref<string | undefined>(undefined);
|
||||
const connectionToFocus = ref<string | undefined>(undefined);
|
||||
const addRow = useTemplateRef<{ $el?: HTMLElement }>('addRow');
|
||||
const list = useTemplateRef<HTMLElement>('list');
|
||||
const page = useTemplateRef<{ $el?: HTMLElement }>('page');
|
||||
|
||||
// The backend accepts a single connection and treats it as the instance
|
||||
// connection. This relaxes once project-level connections land.
|
||||
const canAddConnection = computed(() => !isFetching.value && connections.value.length === 0);
|
||||
|
||||
function describe(connection: GitConnectionSummary) {
|
||||
const provider = i18n.baseText('settings.gitConnections.connectorRow.provider');
|
||||
const repository = connection.branchName
|
||||
? `${connection.repositoryUrl} @ ${connection.branchName}`
|
||||
: connection.repositoryUrl;
|
||||
return `${provider} \u00b7 ${repository}`;
|
||||
}
|
||||
|
||||
let hasLoaded = false;
|
||||
let pendingLoad: Promise<void> = Promise.resolve();
|
||||
|
||||
async function load() {
|
||||
// Only the first load shows the skeleton; a refetch keeps the list mounted so
|
||||
// the dialog still has something to restore focus to.
|
||||
isInitialLoading.value = !hasLoaded;
|
||||
isFetching.value = true;
|
||||
loadError.value = false;
|
||||
try {
|
||||
connections.value = await fetchGitConnections(rootStore.publicApiContext);
|
||||
} catch (error) {
|
||||
loadError.value = true;
|
||||
connections.value = [];
|
||||
toast.showError(error, i18n.baseText('settings.gitConnections.error.title'));
|
||||
} finally {
|
||||
isInitialLoading.value = false;
|
||||
isFetching.value = false;
|
||||
hasLoaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('settings.gitConnections.title'));
|
||||
await load();
|
||||
});
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!canAddConnection.value) return;
|
||||
editingId.value = undefined;
|
||||
connectionToFocus.value = undefined;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(id: string) {
|
||||
editingId.value = id;
|
||||
connectionToFocus.value = id;
|
||||
dialogOpen.value = true;
|
||||
}
|
||||
|
||||
function onSaved(id: string) {
|
||||
connectionToFocus.value = id;
|
||||
pendingLoad = load();
|
||||
}
|
||||
|
||||
async function focusConnection(id: string | undefined) {
|
||||
await nextTick();
|
||||
const row = id
|
||||
? list.value?.querySelector<HTMLElement>(`[data-connection-id="${id}"]`)
|
||||
: undefined;
|
||||
// The page itself is the last resort: a failed refetch replaces both the rows
|
||||
// and the add row with the error state, and reka's own restore is suppressed.
|
||||
(row ?? addRow.value?.$el ?? page.value?.$el)?.focus();
|
||||
}
|
||||
|
||||
async function onDialogOpenChange(open: boolean) {
|
||||
dialogOpen.value = open;
|
||||
if (open) return;
|
||||
// The row to focus only exists once the refetch has rendered.
|
||||
await pendingLoad;
|
||||
await focusConnection(connectionToFocus.value);
|
||||
}
|
||||
|
||||
async function onDelete(id: string) {
|
||||
const confirmed = await message.confirm(
|
||||
i18n.baseText('settings.gitConnections.delete.confirm.message'),
|
||||
i18n.baseText('settings.gitConnections.delete.confirm.title'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText('settings.gitConnections.delete.confirm.button'),
|
||||
customClass: 'el-message-box--destructive',
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed !== MODAL_CONFIRM) return;
|
||||
|
||||
try {
|
||||
await deleteGitConnection(rootStore.publicApiContext, id);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('settings.gitConnections.toast.deleted'),
|
||||
type: 'success',
|
||||
});
|
||||
connectionToFocus.value = undefined;
|
||||
dialogOpen.value = false;
|
||||
await load();
|
||||
await focusConnection(undefined);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.gitConnections.toast.error.delete'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nSettingsLayout ref="page" :class="$style.layout" tabindex="-1">
|
||||
<N8nSettingsPageHeader
|
||||
:title="i18n.baseText('settings.gitConnections.title')"
|
||||
:description="i18n.baseText('settings.gitConnections.description')"
|
||||
:docs-url="DOCS_URL"
|
||||
/>
|
||||
|
||||
<N8nSettingsSection
|
||||
:title="i18n.baseText('settings.gitConnections.connectors.title')"
|
||||
:description="i18n.baseText('settings.gitConnections.connectors.description')"
|
||||
>
|
||||
<N8nLoading2 v-if="isInitialLoading" :rows="2" :shrink-last="false" />
|
||||
<N8nEmptyState
|
||||
v-else-if="loadError"
|
||||
:heading="i18n.baseText('settings.gitConnections.error.title')"
|
||||
:description="i18n.baseText('settings.gitConnections.error.description')"
|
||||
:button-text="i18n.baseText('generic.retry')"
|
||||
data-test-id="git-connections-load-error"
|
||||
@click:button="load"
|
||||
/>
|
||||
<template v-else>
|
||||
<div ref="list" :class="$style.list">
|
||||
<N8nSettingsRowGroup v-for="connection in connections" :key="connection.id">
|
||||
<N8nSettingsRow
|
||||
clickable
|
||||
:title="connection.name"
|
||||
:description="describe(connection)"
|
||||
:data-connection-id="connection.id"
|
||||
data-test-id="git-connection-row"
|
||||
@click="openEditDialog(connection.id)"
|
||||
>
|
||||
<template #visual>
|
||||
<N8nIcon icon="git-branch" color="text-dark" :size="20" />
|
||||
</template>
|
||||
<template #action>
|
||||
<N8nSettingsRowConfigure
|
||||
:value="i18n.baseText('settings.gitConnections.scope.instance')"
|
||||
/>
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
</N8nSettingsRowGroup>
|
||||
</div>
|
||||
|
||||
<N8nSettingsRowGroup v-if="canAddConnection">
|
||||
<N8nSettingsRow
|
||||
ref="addRow"
|
||||
clickable
|
||||
:title="i18n.baseText('settings.gitConnections.addConnector')"
|
||||
:description="i18n.baseText('settings.gitConnections.addConnector.description')"
|
||||
data-test-id="git-connections-add"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #visual>
|
||||
<N8nIcon icon="plus" color="text-dark" :size="20" />
|
||||
</template>
|
||||
<template #action>
|
||||
<N8nIcon icon="chevron-right" color="text-light" size="small" />
|
||||
</template>
|
||||
</N8nSettingsRow>
|
||||
</N8nSettingsRowGroup>
|
||||
</template>
|
||||
</N8nSettingsSection>
|
||||
|
||||
<GitConnectionDialog
|
||||
v-if="dialogOpen"
|
||||
:key="editingId ?? 'new'"
|
||||
:open="dialogOpen"
|
||||
:connection-id="editingId"
|
||||
@update:open="onDialogOpenChange"
|
||||
@saved="onSaved"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</N8nSettingsLayout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
// The settings shell already pads the top of the page.
|
||||
.layout {
|
||||
padding-top: 0;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--xs);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useEnvFeatureFlag } from '@/features/shared/envFeatureFlag/useEnvFeatureFlag';
|
||||
|
||||
/**
|
||||
* Gates all workflow-promotion surfaces. Enabled only when the `git-connections`
|
||||
* module is active and the `N8N_ENV_FEAT_PROMOTIONS` rollout flag is on.
|
||||
*/
|
||||
export const usePromotionsEnabled = () => {
|
||||
const settingsStore = useSettingsStore();
|
||||
const { check } = useEnvFeatureFlag();
|
||||
|
||||
const isEnabled = computed(
|
||||
() => settingsStore.isModuleActive('git-connections') && check.value('PROMOTIONS'),
|
||||
);
|
||||
|
||||
return { isEnabled };
|
||||
};
|
||||
Reference in New Issue
Block a user