feat(core): Add HTTPS protocol support for repository connections (#18250)

Co-authored-by: konstantintieber <konstantin.tieber@n8n.io>
This commit is contained in:
Idir Ouhab Meskine
2025-09-10 16:08:07 +02:00
committed by GitHub
parent eb389a787b
commit 5c6094dfd7
16 changed files with 1858 additions and 162 deletions

View File

@@ -2232,6 +2232,8 @@
"settings.sourceControl.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository. {link}",
"settings.sourceControl.description.link": "More info",
"settings.sourceControl.gitConfig": "Git configuration",
"settings.sourceControl.connectionType": "Connection Type",
"settings.sourceControl.enterValidHttpsUrl": "Please enter a valid HTTPS URL",
"settings.sourceControl.repoUrl": "Git repository URL (SSH)",
"settings.sourceControl.repoUrlPlaceholder": "e.g. git{'@'}github.com:my-team/my-repository",
"settings.sourceControl.repoUrlInvalid": "The Git repository URL is not valid",
@@ -2327,6 +2329,20 @@
"settings.sourceControl.docs.using.pushPull.url": "https://docs.n8n.io/source-control-environments/using/push-pull",
"settings.sourceControl.error.not.connected.title": "Environments have not been enabled",
"settings.sourceControl.error.not.connected.message": "Please head over to <a href='/settings/environments'>environment settings</a> to connect a git repository first to activate this functionality.",
"settings.sourceControl.saved.error": "Error setting branch",
"settings.sourceControl.sshRepoUrl": "SSH Repository URL",
"settings.sourceControl.httpsRepoUrl": "HTTPS Repository URL",
"settings.sourceControl.sshRepoUrlPlaceholder": "git{'@'}github.com:user/repository.git",
"settings.sourceControl.httpsRepoUrlPlaceholder": "https://github.com/user/repository.git",
"settings.sourceControl.sshFormatNotice": "Use SSH format: git{'@'}github.com:user/repository.git",
"settings.sourceControl.httpsFormatNotice": "Use HTTPS format: https://github.com/user/repository.git",
"settings.sourceControl.httpsUsername": "Username",
"settings.sourceControl.httpsUsernamePlaceholder": "Enter your GitHub username",
"settings.sourceControl.httpsPersonalAccessToken": "Personal Access Token",
"settings.sourceControl.httpsPersonalAccessTokenPlaceholder": "Enter your Personal Access Token (PAT)",
"settings.sourceControl.httpsWarningNotice": "{strong} Create a Personal Access Token at GitHub Settings → Developer settings → Personal access tokens → Tokens (classic). Required scopes: {repo} for private repositories or {publicRepo} for public ones.",
"settings.sourceControl.httpsWarningNotice.strong": "Personal Access Token required:",
"settings.sourceControl.httpsCredentialsNotice": "Credentials are securely encrypted and stored locally",
"showMessage.cancel": "@:_reusableBaseText.cancel",
"showMessage.ok": "OK",
"showMessage.showDetails": "Show Details",

View File

@@ -0,0 +1,272 @@
import { createPinia, setActivePinia } from 'pinia';
import { vi } from 'vitest';
import * as vcApi from '@/api/sourceControl';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { SourceControlPreferences } from '@/types/sourceControl.types';
import type { SourceControlledFile } from '@n8n/api-types';
vi.mock('@/api/sourceControl');
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: vi.fn(() => ({
restApiContext: {},
})),
}));
describe('useSourceControlStore', () => {
let pinia: ReturnType<typeof createPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
sourceControlStore = useSourceControlStore();
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('initial state', () => {
it('should initialize with default preferences', () => {
expect(sourceControlStore.preferences.connectionType).toBe('ssh');
expect(sourceControlStore.preferences.branchName).toBe('');
expect(sourceControlStore.preferences.repositoryUrl).toBe('');
expect(sourceControlStore.preferences.connected).toBe(false);
expect(sourceControlStore.preferences.keyGeneratorType).toBe('ed25519');
});
it('should have ssh key types with labels available', () => {
expect(sourceControlStore.sshKeyTypesWithLabel).toEqual([
{ value: 'ed25519', label: 'ED25519' },
{ value: 'rsa', label: 'RSA' },
]);
});
});
describe('savePreferences', () => {
it('should call API with HTTPS credentials', async () => {
const preferences = {
repositoryUrl: 'https://github.com/user/repo.git',
branchName: 'main',
connectionType: 'https' as const,
httpsUsername: 'testuser',
httpsPassword: 'testtoken',
};
const mockSavePreferences = vi.mocked(vcApi.savePreferences);
mockSavePreferences.mockResolvedValue({} as SourceControlPreferences);
await sourceControlStore.savePreferences(preferences);
expect(mockSavePreferences).toHaveBeenCalledWith(
{}, // restApiContext
preferences,
);
});
it('should call API with SSH preferences', async () => {
const preferences = {
repositoryUrl: 'git@github.com:user/repo.git',
branchName: 'main',
connectionType: 'ssh' as const,
keyGeneratorType: 'ed25519' as const,
};
const mockSavePreferences = vi.mocked(vcApi.savePreferences);
mockSavePreferences.mockResolvedValue({} as SourceControlPreferences);
await sourceControlStore.savePreferences(preferences);
expect(mockSavePreferences).toHaveBeenCalledWith(
{}, // restApiContext
preferences,
);
});
it('should update local preferences after successful API call', async () => {
const preferences: SourceControlPreferences = {
repositoryUrl: 'https://github.com/user/repo.git',
branchName: 'main',
connectionType: 'https' as const,
connected: true,
branchColor: '#4f46e5',
branchReadOnly: false,
branches: ['main', 'develop'],
};
const mockSavePreferences = vi.mocked(vcApi.savePreferences);
mockSavePreferences.mockResolvedValue(preferences);
await sourceControlStore.savePreferences(preferences);
expect(sourceControlStore.preferences.repositoryUrl).toBe(preferences.repositoryUrl);
expect(sourceControlStore.preferences.branchName).toBe(preferences.branchName);
expect(sourceControlStore.preferences.connectionType).toBe(preferences.connectionType);
expect(sourceControlStore.preferences.connected).toBe(preferences.connected);
});
});
describe('generateKeyPair', () => {
it('should call API and update public key', async () => {
const keyType = 'ed25519';
const mockPublicKey = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITest';
const mockGenerateKeyPair = vi.mocked(vcApi.generateKeyPair);
mockGenerateKeyPair.mockResolvedValue('ssh-ed25519 AAAAC3NzaC1lZDI...');
const mockGetPreferences = vi.mocked(vcApi.getPreferences);
mockGetPreferences.mockImplementation(async () => {
return {
connected: false,
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#000000',
branches: [],
branchName: '',
publicKey: mockPublicKey,
} satisfies SourceControlPreferences;
});
await sourceControlStore.generateKeyPair(keyType);
expect(mockGenerateKeyPair).toHaveBeenCalledWith({}, keyType);
expect(sourceControlStore.preferences.publicKey).toBe(mockPublicKey);
expect(sourceControlStore.preferences.keyGeneratorType).toBe(keyType);
});
it('should handle API errors', async () => {
const mockGenerateKeyPair = vi.mocked(vcApi.generateKeyPair);
mockGenerateKeyPair.mockRejectedValue(new Error('API Error'));
await expect(sourceControlStore.generateKeyPair('rsa')).rejects.toThrow('API Error');
});
});
describe('getBranches', () => {
it('should call API and update branches list', async () => {
const mockBranches = {
branches: ['main', 'develop', 'feature/test'],
currentBranch: 'main',
};
const mockGetBranches = vi.mocked(vcApi.getBranches);
mockGetBranches.mockResolvedValue(mockBranches);
await sourceControlStore.getBranches();
expect(mockGetBranches).toHaveBeenCalledWith({});
expect(sourceControlStore.preferences.branches).toEqual(mockBranches.branches);
});
});
describe('disconnect', () => {
it('should call API and reset preferences', async () => {
sourceControlStore.preferences.connected = true;
sourceControlStore.preferences.repositoryUrl = 'https://github.com/user/repo.git';
sourceControlStore.preferences.branchName = 'main';
const mockDisconnect = vi.mocked(vcApi.disconnect);
mockDisconnect.mockResolvedValue('Disconnected successfully');
await sourceControlStore.disconnect(false);
expect(mockDisconnect).toHaveBeenCalledWith({}, false);
expect(sourceControlStore.preferences.connected).toBe(false);
expect(sourceControlStore.preferences.branches).toEqual([]);
});
});
describe('pushWorkfolder', () => {
it('should call API with correct parameters', async () => {
const data = {
commitMessage: 'Test commit',
fileNames: [
{
id: 'workflow1',
name: 'Test Workflow',
type: 'workflow' as const,
status: 'modified' as const,
location: 'local' as const,
conflict: false,
file: '/path/to/workflow.json',
updatedAt: '2024-01-01T00:00:00.000Z',
pushed: false,
},
],
force: false,
};
const mockPushWorkfolder = vi.mocked(vcApi.pushWorkfolder);
mockPushWorkfolder.mockResolvedValue(undefined);
await sourceControlStore.pushWorkfolder(data);
expect(mockPushWorkfolder).toHaveBeenCalledWith(
{}, // restApiContext
{
force: data.force,
commitMessage: data.commitMessage,
fileNames: data.fileNames,
},
);
expect(sourceControlStore.state.commitMessage).toBe(data.commitMessage);
});
});
describe('pullWorkfolder', () => {
it('should call API with correct parameters', async () => {
const force = true;
const mockResult: SourceControlledFile[] = [
{
file: 'test.json',
id: 'test-id',
name: 'test-workflow',
type: 'workflow',
status: 'new',
location: 'local',
conflict: false,
updatedAt: '2023-01-01T00:00:00.000Z',
},
];
const mockPullWorkfolder = vi.mocked(vcApi.pullWorkfolder);
mockPullWorkfolder.mockResolvedValue(mockResult);
const result = await sourceControlStore.pullWorkfolder(force);
expect(mockPullWorkfolder).toHaveBeenCalledWith({}, { force });
expect(result).toEqual(mockResult);
});
});
describe('getAggregatedStatus', () => {
it('should call API and return status', async () => {
const mockStatus: SourceControlledFile[] = [
{
id: 'workflow1',
name: 'Test Workflow',
type: 'workflow' as const,
status: 'modified' as const,
location: 'local' as const,
conflict: false,
file: '/path/to/workflow.json',
updatedAt: '2024-01-01T00:00:00.000Z',
pushed: false,
},
];
const mockGetAggregatedStatus = vi.mocked(vcApi.getAggregatedStatus);
mockGetAggregatedStatus.mockResolvedValue(mockStatus);
const result = await sourceControlStore.getAggregatedStatus();
expect(mockGetAggregatedStatus).toHaveBeenCalledWith({});
expect(result).toEqual(mockStatus);
});
});
});

View File

@@ -30,6 +30,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
connected: false,
publicKey: '',
keyGeneratorType: 'ed25519',
connectionType: 'ssh',
});
const state = reactive<{

View File

@@ -12,6 +12,7 @@ export type SourceControlPreferences = {
publicKey?: string;
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
currentBranch?: string;
connectionType?: 'ssh' | 'https';
};
export interface SourceControlStatus {

View File

@@ -150,6 +150,58 @@ describe('SettingsSourceControl', () => {
expect(generateKeyPairSpy).toHaveBeenCalledWith('rsa');
}, 10000);
describe('Protocol Selection', () => {
beforeEach(() => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;
});
it('should show SSH-specific fields when SSH protocol is selected', async () => {
await nextTick();
const { container, getByTestId } = renderComponent({
pinia,
});
await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual(''));
// SSH should be selected by default
const connectionTypeSelect = getByTestId('source-control-connection-type-select');
expect(within(connectionTypeSelect).getByDisplayValue('SSH')).toBeInTheDocument();
// SSH-specific fields should be visible
expect(getByTestId('source-control-ssh-key-type-select')).toBeInTheDocument();
expect(getByTestId('source-control-refresh-ssh-key-button')).toBeInTheDocument();
expect(container.querySelector('input[name="repoUrl"]')).toBeInTheDocument();
// HTTPS-specific fields should not be visible
expect(container.querySelector('input[name="httpsUsername"]')).not.toBeInTheDocument();
expect(container.querySelector('input[name="httpsPassword"]')).not.toBeInTheDocument();
});
it('should show HTTPS-specific fields when HTTPS protocol is selected', async () => {
await nextTick();
const { container, queryByTestId } = renderComponent({
pinia,
});
await waitFor(() => expect(sourceControlStore.preferences.publicKey).not.toEqual(''));
// Change to HTTPS protocol
const connectionTypeSelect = queryByTestId('source-control-connection-type-select')!;
await userEvent.click(within(connectionTypeSelect).getByRole('combobox'));
await waitFor(() => expect(screen.getByText('HTTPS')).toBeVisible());
await userEvent.click(screen.getByText('HTTPS'));
// HTTPS-specific fields should be visible
expect(container.querySelector('input[name="httpsUsername"]')).toBeInTheDocument();
expect(container.querySelector('input[name="httpsPassword"]')).toBeInTheDocument();
expect(container.querySelector('input[name="repoUrl"]')).toBeInTheDocument();
// SSH-specific fields should not be visible
expect(queryByTestId('source-control-ssh-key-type-select')).not.toBeInTheDocument();
expect(queryByTestId('source-control-refresh-ssh-key-button')).not.toBeInTheDocument();
});
});
describe('should test repo URLs', () => {
beforeEach(() => {
settingsStore.settings.enterprise[EnterpriseEditionFeature.SourceControl] = true;

View File

@@ -7,12 +7,12 @@ import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper
import { useToast } from '@/composables/useToast';
import { MODAL_CONFIRM } from '@/constants';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import type { SshKeyTypes } from '@/types/sourceControl.types';
import type { SshKeyTypes, SourceControlPreferences } from '@/types/sourceControl.types';
import type { TupleToUnion } from '@/utils/typeHelpers';
import type { Rule, RuleGroup } from '@n8n/design-system/types';
import { useI18n } from '@n8n/i18n';
import type { Validatable } from '@n8n/design-system';
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { I18nT } from 'vue-i18n';
const locale = useI18n();
@@ -24,6 +24,10 @@ const documentTitle = useDocumentTitle();
const loadingService = useLoadingService();
const isConnected = ref(false);
const connectionType = ref<'ssh' | 'https'>('ssh');
const httpsUsername = ref('');
const httpsPassword = ref('');
const branchNameOptions = computed(() =>
sourceControlStore.preferences.branches.map((branch) => ({
value: branch,
@@ -31,13 +35,29 @@ const branchNameOptions = computed(() =>
})),
);
const connectionTypeOptions = [
{ value: 'ssh', label: 'SSH' },
{ value: 'https', label: 'HTTPS' },
];
const onConnect = async () => {
loadingService.startLoading();
loadingService.setLoadingText(locale.baseText('settings.sourceControl.loading.connecting'));
try {
await sourceControlStore.savePreferences({
const connectionData: Partial<SourceControlPreferences> & {
httpsUsername?: string;
httpsPassword?: string;
} = {
repositoryUrl: sourceControlStore.preferences.repositoryUrl,
});
connectionType: connectionType.value,
};
if (connectionType.value === 'https') {
connectionData.httpsUsername = httpsUsername.value;
connectionData.httpsPassword = httpsPassword.value;
}
await sourceControlStore.savePreferences(connectionData);
await sourceControlStore.getBranches();
isConnected.value = true;
toast.showMessage({
@@ -51,6 +71,11 @@ const onConnect = async () => {
loadingService.stopLoading();
};
const onSubmitConnectionForm = (event: Event) => {
event.preventDefault();
void onConnect();
};
const onDisconnect = async () => {
try {
const confirmation = await message.confirm(
@@ -66,6 +91,8 @@ const onDisconnect = async () => {
loadingService.startLoading();
await sourceControlStore.disconnect(true);
isConnected.value = false;
httpsUsername.value = '';
httpsPassword.value = '';
toast.showMessage({
title: locale.baseText('settings.sourceControl.toast.disconnected.title'),
message: locale.baseText('settings.sourceControl.toast.disconnected.message'),
@@ -91,7 +118,7 @@ const onSave = async () => {
type: 'success',
});
} catch (error) {
toast.showError(error, 'Error setting branch');
toast.showError(error, locale.baseText('settings.sourceControl.saved.error'));
}
loadingService.stopLoading();
};
@@ -111,6 +138,7 @@ const initialize = async () => {
await sourceControlStore.getPreferences();
if (sourceControlStore.preferences.connected) {
isConnected.value = true;
connectionType.value = sourceControlStore.preferences.connectionType || 'ssh';
void sourceControlStore.getBranches();
}
};
@@ -124,27 +152,54 @@ onMounted(async () => {
const formValidationStatus = reactive<Record<string, boolean>>({
repoUrl: false,
keyGeneratorType: false,
httpsUsername: false,
httpsPassword: false,
});
function onValidate(key: string, value: boolean) {
formValidationStatus[key] = value;
}
const repoUrlValidationRules: Array<Rule | RuleGroup> = [
{ name: 'REQUIRED' },
{
name: 'MATCH_REGEX',
config: {
regex:
/^(?:git@|ssh:\/\/git@|[\w-]+@)(?:[\w.-]+|\[[0-9a-fA-F:]+])(?::\d+)?[:\/][\w\-~.]+(?:\/[\w\-~.]+)*(?:\.git)?(?:\/.*)?$/,
message: locale.baseText('settings.sourceControl.repoUrlInvalid'),
},
},
];
const repoUrlValidationRules = computed<Array<Rule | RuleGroup>>(() => {
const baseRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
if (connectionType.value === 'ssh') {
baseRules.push({
name: 'MATCH_REGEX',
config: {
regex:
/^(?:git@|ssh:\/\/git@|[\w-]+@)(?:[\w.-]+|\[[0-9a-fA-F:]+])(?::\d+)?[:\/][\w\-~.]+(?:\/[\w\-~.]+)*(?:\.git)?(?:\/.*)?$/,
message: locale.baseText('settings.sourceControl.repoUrlInvalid'),
},
});
} else {
baseRules.push({
name: 'MATCH_REGEX',
config: {
regex: /^https?:\/\/.+$/,
message: locale.baseText('settings.sourceControl.enterValidHttpsUrl'),
},
});
}
return baseRules;
});
const keyGeneratorTypeValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
const httpsCredentialValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
const validForConnection = computed(() => {
if (connectionType.value === 'ssh') {
return formValidationStatus.repoUrl;
} else {
return (
formValidationStatus.repoUrl &&
formValidationStatus.httpsUsername &&
formValidationStatus.httpsPassword
);
}
});
const validForConnection = computed(() => formValidationStatus.repoUrl);
const branchNameValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
async function refreshSshKey() {
@@ -189,6 +244,16 @@ const onSelectSshKeyType = (value: Validatable) => {
}
sourceControlStore.preferences.keyGeneratorType = sshKeyType;
};
watch(connectionType, () => {
formValidationStatus.repoUrl = false;
formValidationStatus.httpsUsername = false;
formValidationStatus.httpsPassword = false;
if (!isConnected.value) {
sourceControlStore.preferences.repositoryUrl = '';
}
});
</script>
<template>
@@ -212,90 +277,185 @@ const onSelectSshKeyType = (value: Validatable) => {
<n8n-heading size="xlarge" tag="h2" class="mb-s">{{
locale.baseText('settings.sourceControl.gitConfig')
}}</n8n-heading>
<div :class="$style.group">
<label for="repoUrl">{{ locale.baseText('settings.sourceControl.repoUrl') }}</label>
<div :class="$style.groupFlex">
<form @submit="onSubmitConnectionForm">
<div v-if="!isConnected" :class="$style.group">
<label for="connectionType">{{
locale.baseText('settings.sourceControl.connectionType')
}}</label>
<n8n-form-input
id="repoUrl"
v-model="sourceControlStore.preferences.repositoryUrl"
label=""
class="ml-0"
name="repoUrl"
validate-on-blur
:validation-rules="repoUrlValidationRules"
:disabled="isConnected"
:placeholder="locale.baseText('settings.sourceControl.repoUrlPlaceholder')"
@validate="(value: boolean) => onValidate('repoUrl', value)"
/>
<n8n-button
v-if="isConnected"
:class="$style.disconnectButton"
type="tertiary"
size="large"
icon="trash-2"
data-test-id="source-control-disconnect-button"
@click="onDisconnect"
>{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button
>
</div>
</div>
<div v-if="sourceControlStore.preferences.publicKey" :class="$style.group">
<label>{{ locale.baseText('settings.sourceControl.sshKey') }}</label>
<div :class="{ [$style.sshInput]: !isConnected }">
<n8n-form-input
v-if="!isConnected"
id="keyGeneratorType"
:class="$style.sshKeyTypeSelect"
id="connectionType"
v-model="connectionType"
label=""
type="select"
name="keyGeneratorType"
data-test-id="source-control-ssh-key-type-select"
validate-on-blur
:validation-rules="keyGeneratorTypeValidationRules"
:options="sourceControlStore.sshKeyTypesWithLabel"
:model-value="sourceControlStore.preferences.keyGeneratorType"
@validate="(value: boolean) => onValidate('keyGeneratorType', value)"
@update:model-value="onSelectSshKeyType"
name="connectionType"
:options="connectionTypeOptions"
data-test-id="source-control-connection-type-select"
/>
<CopyInput
:class="$style.copyInput"
collapse
size="medium"
:value="sourceControlStore.preferences.publicKey"
:copy-button-text="locale.baseText('generic.clickToCopy')"
/>
<n8n-button
v-if="!isConnected"
size="large"
type="tertiary"
icon="refresh-cw"
data-test-id="source-control-refresh-ssh-key-button"
@click="refreshSshKey"
>
{{ locale.baseText('settings.sourceControl.refreshSshKey') }}
</n8n-button>
</div>
<n8n-notice type="info" class="mt-s">
<I18nT keypath="settings.sourceControl.sshKeyDescription" tag="span" scope="global">
<template #link>
<a
:href="locale.baseText('settings.sourceControl.docs.setup.ssh.url')"
target="_blank"
>{{ locale.baseText('settings.sourceControl.sshKeyDescriptionLink') }}</a
>
</template>
</I18nT>
</n8n-notice>
</div>
<n8n-button
v-if="!isConnected"
size="large"
:disabled="!validForConnection"
:class="$style.connect"
data-test-id="source-control-connect-button"
@click="onConnect"
>{{ locale.baseText('settings.sourceControl.button.connect') }}</n8n-button
>
<!-- Repository URL -->
<div :class="$style.group">
<label for="repoUrl">
{{
connectionType === 'ssh'
? locale.baseText('settings.sourceControl.sshRepoUrl')
: locale.baseText('settings.sourceControl.httpsRepoUrl')
}}
</label>
<div :class="$style.groupFlex">
<n8n-form-input
id="repoUrl"
v-model="sourceControlStore.preferences.repositoryUrl"
label=""
class="ml-0"
name="repoUrl"
validate-on-blur
:validation-rules="repoUrlValidationRules"
:disabled="isConnected"
:placeholder="
connectionType === 'ssh'
? locale.baseText('settings.sourceControl.sshRepoUrlPlaceholder')
: locale.baseText('settings.sourceControl.httpsRepoUrlPlaceholder')
"
@validate="(value: boolean) => onValidate('repoUrl', value)"
/>
<n8n-button
v-if="isConnected"
:class="$style.disconnectButton"
type="tertiary"
size="large"
icon="trash-2"
data-test-id="source-control-disconnect-button"
@click="onDisconnect"
>{{ locale.baseText('settings.sourceControl.button.disconnect') }}</n8n-button
>
</div>
<n8n-notice v-if="!isConnected && connectionType === 'ssh'" type="info" class="mt-s">
{{ locale.baseText('settings.sourceControl.sshFormatNotice') }}
</n8n-notice>
<n8n-notice v-if="!isConnected && connectionType === 'https'" type="info" class="mt-s">
{{ locale.baseText('settings.sourceControl.httpsFormatNotice') }}
</n8n-notice>
</div>
<div v-if="connectionType === 'https' && !isConnected" :class="$style.group">
<label for="httpsUsername">{{
locale.baseText('settings.sourceControl.httpsUsername')
}}</label>
<n8n-form-input
id="httpsUsername"
v-model="httpsUsername"
label=""
name="httpsUsername"
type="text"
validate-on-blur
:validation-rules="httpsCredentialValidationRules"
:placeholder="locale.baseText('settings.sourceControl.httpsUsernamePlaceholder')"
@validate="(value: boolean) => onValidate('httpsUsername', value)"
/>
</div>
<div v-if="connectionType === 'https' && !isConnected" :class="$style.group">
<label for="httpsPassword">{{
locale.baseText('settings.sourceControl.httpsPersonalAccessToken')
}}</label>
<n8n-form-input
id="httpsPassword"
v-model="httpsPassword"
label=""
name="httpsPassword"
type="password"
validate-on-blur
:validation-rules="httpsCredentialValidationRules"
:placeholder="
locale.baseText('settings.sourceControl.httpsPersonalAccessTokenPlaceholder')
"
@validate="(value: boolean) => onValidate('httpsPassword', value)"
/>
<n8n-notice type="warning" class="mt-s">
<I18nT keypath="settings.sourceControl.httpsWarningNotice" tag="span" scope="global">
<template #strong>
<strong>{{
locale.baseText('settings.sourceControl.httpsWarningNotice.strong')
}}</strong>
</template>
<template #repo>
<code>repo</code>
</template>
<template #publicRepo>
<code>public_repo</code>
</template>
</I18nT>
</n8n-notice>
<n8n-notice type="info" class="mt-s">
{{ locale.baseText('settings.sourceControl.httpsCredentialsNotice') }}
</n8n-notice>
</div>
<div
v-if="connectionType === 'ssh' && sourceControlStore.preferences.publicKey"
:class="$style.group"
>
<label>{{ locale.baseText('settings.sourceControl.sshKey') }}</label>
<div :class="{ [$style.sshInput]: !isConnected }">
<n8n-form-input
v-if="!isConnected"
id="keyGeneratorType"
:class="$style.sshKeyTypeSelect"
label=""
type="select"
name="keyGeneratorType"
data-test-id="source-control-ssh-key-type-select"
validate-on-blur
:validation-rules="keyGeneratorTypeValidationRules"
:options="sourceControlStore.sshKeyTypesWithLabel"
:model-value="sourceControlStore.preferences.keyGeneratorType"
@validate="(value: boolean) => onValidate('keyGeneratorType', value)"
@update:model-value="onSelectSshKeyType"
/>
<CopyInput
:class="$style.copyInput"
collapse
size="medium"
:value="sourceControlStore.preferences.publicKey"
:copy-button-text="locale.baseText('generic.clickToCopy')"
/>
<n8n-button
v-if="!isConnected"
size="large"
type="tertiary"
icon="refresh-cw"
data-test-id="source-control-refresh-ssh-key-button"
@click="refreshSshKey"
>
{{ locale.baseText('settings.sourceControl.refreshSshKey') }}
</n8n-button>
</div>
<n8n-notice type="info" class="mt-s">
<I18nT keypath="settings.sourceControl.sshKeyDescription" tag="span" scope="global">
<template #link>
<a
:href="locale.baseText('settings.sourceControl.docs.setup.ssh.url')"
target="_blank"
>{{ locale.baseText('settings.sourceControl.sshKeyDescriptionLink') }}</a
>
</template>
</I18nT>
</n8n-notice>
</div>
<n8n-button
v-if="!isConnected"
size="large"
:disabled="!validForConnection"
:class="$style.connect"
data-test-id="source-control-connect-button"
@click="onConnect"
>{{ locale.baseText('settings.sourceControl.button.connect') }}</n8n-button
>
</form>
<div v-if="isConnected" data-test-id="source-control-connected-content">
<div :class="$style.group">
<hr />