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:
Jan Kalkan
2026-08-27 14:27:15 +00:00
committed by GitHub
parent e60c43112c
commit 771a5c43f9
16 changed files with 1581 additions and 2 deletions
@@ -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;
}
@@ -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>
@@ -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}`,
});
};
@@ -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({});
});
});
@@ -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;
}
@@ -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');
});
});
@@ -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 };
};