feat: Environments release using source control (#6653)

* initial telemetry setup and adjusted pull return

* quicksave before merge

* feat: add conflicting workflow list to pull modal

* feat: update source control pull modal

* fix: fix linting issue

* feat: add Enter keydown event for submitting source control push modal (no-changelog)

feat: add Enter keydown event for submitting source control push modal

* quicksave

* user workflow table for export

* improve telemetry data

* pull api telemetry

* fix lint

* Copy tweaks.

* remove authorName and authorEmail and pick from user

* rename owners.json to workflow_owners.json

* ignore credential conflicts on pull

* feat: several push/pull flow changes and design update

* pull and push return same data format

* fix: add One last step toast for successful pull

* feat: add up to date pull toast

* fix: add proper Learn more link for push and pull modals

* do not await tracking being sent

* fix import

* fix await

* add more sourcecontrolfile status

* Minor copy tweak for "More info".

* Minor copy tweak for "More info".

* ignore variable_stub conflicts on pull

* ignore whitespace differences

* do not show remote workflows that are not yet created

* fix telemetry

* fix toast when pulling deleted wf

* lint fix

* refactor and make some imports dynamic

* fix variable edit validation

* fix telemetry response

* improve telemetry

* fix unintenional delete commit

* fix status unknown issue

* fix up to date toast

* do not export active state and reapply versionid

* use update instead of upsert

* fix: show all workflows when clicking push to git

* feat: update Up to date pull translation

* fix: update read only env checks

* do not update versionid of only active flag changes

* feat: prevent access to new workflow and templates import when read only env

* feat: send only active state and version if workflow state is not dirty

* fix: Detect when only active state has changed and prevent generation a new version ID

* feat: improve readonly env messages

* make getPreferences public

* fix telemetry issue

* fix: add partial workflow update based on dirty state when changing active state

* update unit tests

* fix: remove unsaved changes check in readOnlyEnv

* fix: disable push to git button when read onyl env

* fix: update readonly toast duration

* fix: fix pinning and title input in protected mode

* initial commit (NOT working)

* working push

* cleanup and implement pull

* fix getstatus

* update import to new method

* var and tag diffs are no conflicts

* only show pull conflict for workflows

* refactor and ignore faulty credentials

* add sanitycheck for missing git folder

* prefer fetch over pull and limit depth to 1

* back to pull...

* fix setting branch on initial connect

* fix test

* remove clean workfolder

* refactor: Remove some unnecessary code

* Fixed links to docs.

* fix getstatus query params

* lint fix

* dialog to show local and remote name on conflict

* only show remote name on conflict

* fix credential expression export

* fix: Broken test

* dont show toast on pull with empty var/tags and refactor

* apply frontend changes from old branch

* fix tag with same name import

* fix buttons shown for non instance owners

* prepare local storage key for removal

* refactor: Change wording on pushing and pulling

* refactor: Change menu item

* test: Fix broken test

* Update packages/cli/src/environments/sourceControl/types/sourceControlPushWorkFolder.ts

Co-authored-by: Iván Ovejero <ivov.src@gmail.com>

---------

Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Giulio Andreini <g.andreini@gmail.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
This commit is contained in:
Michael Auerswald 2023-07-26 09:25:01 +02:00 committed by GitHub
parent bcfc5e717b
commit fc7aa8bd66
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 2210 additions and 1064 deletions

View file

@ -54,6 +54,14 @@ function userToPayload(user: User): {
export class InternalHooks implements IInternalHooksClass {
private instanceId: string;
public get telemetryInstanceId(): string {
return this.instanceId;
}
public get telemetryInstance(): Telemetry {
return this.telemetry;
}
constructor(
private telemetry: Telemetry,
private nodeTypes: NodeTypes,
@ -1043,4 +1051,53 @@ export class InternalHooks implements IInternalHooksClass {
async onVariableCreated(createData: { variable_type: string }): Promise<void> {
return this.telemetry.track('User created variable', createData);
}
async onSourceControlSettingsUpdated(data: {
branch_name: string;
read_only_instance: boolean;
repo_type: 'github' | 'gitlab' | 'other';
connected: boolean;
}): Promise<void> {
return this.telemetry.track('User updated source control settings', data);
}
async onSourceControlUserStartedPullUI(data: {
workflow_updates: number;
workflow_conflicts: number;
cred_conflicts: number;
}): Promise<void> {
return this.telemetry.track('User started pull via UI', data);
}
async onSourceControlUserFinishedPullUI(data: { workflow_updates: number }): Promise<void> {
return this.telemetry.track('User finished pull via UI', {
workflow_updates: data.workflow_updates,
});
}
async onSourceControlUserPulledAPI(data: {
workflow_updates: number;
forced: boolean;
}): Promise<void> {
return this.telemetry.track('User pulled via API', data);
}
async onSourceControlUserStartedPushUI(data: {
workflows_eligible: number;
workflows_eligible_with_conflicts: number;
creds_eligible: number;
creds_eligible_with_conflicts: number;
variables_eligible: number;
}): Promise<void> {
return this.telemetry.track('User started push via UI', data);
}
async onSourceControlUserFinishedPushUI(data: {
workflows_eligible: number;
workflows_pushed: number;
creds_pushed: number;
variables_pushed: number;
}): Promise<void> {
return this.telemetry.track('User finished push via UI', data);
}
}

View file

@ -217,7 +217,7 @@ export const handleLdapInit = async (): Promise<void> => {
try {
await setGlobalLdapConfigVariables(ldapConfig);
} catch (error) {
Logger.error(
Logger.warn(
`Cannot set LDAP login enabled state when an authentication method other than email or ldap is active (current: ${getCurrentAuthenticationMethod()})`,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
error,

View file

@ -6,7 +6,11 @@ import { authorize } from '../../shared/middlewares/global.middleware';
import type { ImportResult } from '@/environments/sourceControl/types/importResult';
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
import { isSourceControlLicensed } from '@/environments/sourceControl/sourceControlHelper.ee';
import {
getTrackingInformationFromPullResult,
isSourceControlLicensed,
} from '@/environments/sourceControl/sourceControlHelper.ee';
import { InternalHooks } from '@/InternalHooks';
export = {
pull: [
@ -32,12 +36,16 @@ export = {
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
importAfterPull: true,
});
if ((result as ImportResult)?.workflows) {
return res.status(200).send(result as ImportResult);
if (result.statusCode === 200) {
void Container.get(InternalHooks).onSourceControlUserPulledAPI({
...getTrackingInformationFromPullResult(result.statusResult),
forced: req.body.force ?? false,
});
return res.status(200).send(result.statusResult);
} else {
return res.status(409).send(result);
return res.status(409).send(result.statusResult);
}
} catch (error) {
return res.status(400).send((error as { message: string }).message);

View file

@ -5,7 +5,7 @@ export const SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER = 'workflows';
export const SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER = 'credential_stubs';
export const SOURCE_CONTROL_VARIABLES_EXPORT_FILE = 'variable_stubs.json';
export const SOURCE_CONTROL_TAGS_EXPORT_FILE = 'tags.json';
export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'owners.json';
export const SOURCE_CONTROL_OWNERS_EXPORT_FILE = 'workflow_owners.json';
export const SOURCE_CONTROL_SSH_FOLDER = 'ssh';
export const SOURCE_CONTROL_SSH_KEY_NAME = 'key';
export const SOURCE_CONTROL_DEFAULT_BRANCH = 'main';
@ -14,3 +14,5 @@ export const SOURCE_CONTROL_API_ROOT = 'source-control';
export const SOURCE_CONTROL_README = `
# n8n Source Control
`;
export const SOURCE_CONTROL_DEFAULT_NAME = 'n8n user';
export const SOURCE_CONTROL_DEFAULT_EMAIL = 'n8n@example.com';

View file

@ -1,6 +1,3 @@
import express from 'express';
import { Service } from 'typedi';
import type { PullResult, PushResult, StatusResult } from 'simple-git';
import { Authorized, Get, Post, Patch, RestController } from '@/decorators';
import {
sourceControlLicensedMiddleware,
@ -8,12 +5,18 @@ import {
} from './middleware/sourceControlEnabledMiddleware.ee';
import { SourceControlService } from './sourceControl.service.ee';
import { SourceControlRequest } from './types/requests';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import { BadRequestError } from '@/ResponseHelper';
import type { ImportResult } from './types/importResult';
import { SourceControlPreferencesService } from './sourceControlPreferences.service.ee';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import type { SourceControlledFile } from './types/sourceControlledFile';
import { SOURCE_CONTROL_API_ROOT, SOURCE_CONTROL_DEFAULT_BRANCH } from './constants';
import { BadRequestError } from '@/ResponseHelper';
import type { PullResult } from 'simple-git';
import express from 'express';
import type { ImportResult } from './types/importResult';
import Container, { Service } from 'typedi';
import { InternalHooks } from '../../InternalHooks';
import { getRepoType } from './sourceControlHelper.ee';
import { SourceControlGetStatus } from './types/sourceControlGetStatus';
@Service()
@RestController(`/${SOURCE_CONTROL_API_ROOT}`)
@ -23,7 +26,7 @@ export class SourceControlController {
private sourceControlPreferencesService: SourceControlPreferencesService,
) {}
@Authorized('any')
@Authorized('none')
@Get('/preferences', { middlewares: [sourceControlLicensedMiddleware] })
async getPreferences(): Promise<SourceControlPreferences> {
// returns the settings with the privateKey property redacted
@ -56,14 +59,17 @@ export class SourceControlController {
);
if (sanitizedPreferences.initRepo === true) {
try {
await this.sourceControlService.initializeRepository({
...updatedPreferences,
branchName:
updatedPreferences.branchName === ''
? SOURCE_CONTROL_DEFAULT_BRANCH
: updatedPreferences.branchName,
initRepo: true,
});
await this.sourceControlService.initializeRepository(
{
...updatedPreferences,
branchName:
updatedPreferences.branchName === ''
? SOURCE_CONTROL_DEFAULT_BRANCH
: updatedPreferences.branchName,
initRepo: true,
},
req.user,
);
if (this.sourceControlPreferencesService.getPreferences().branchName !== '') {
await this.sourceControlPreferencesService.setPreferences({
connected: true,
@ -76,7 +82,17 @@ export class SourceControlController {
}
}
await this.sourceControlService.init();
return this.sourceControlPreferencesService.getPreferences();
const resultingPreferences = this.sourceControlPreferencesService.getPreferences();
// #region Tracking Information
// located in controller so as to not call this multiple times when updating preferences
void Container.get(InternalHooks).onSourceControlSettingsUpdated({
branch_name: resultingPreferences.branchName,
connected: resultingPreferences.connected,
read_only_instance: resultingPreferences.branchReadOnly,
repo_type: getRepoType(resultingPreferences.repositoryUrl),
});
// #endregion
return resultingPreferences;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -92,8 +108,6 @@ export class SourceControlController {
connected: undefined,
publicKey: undefined,
repositoryUrl: undefined,
authorName: undefined,
authorEmail: undefined,
};
const currentPreferences = this.sourceControlPreferencesService.getPreferences();
await this.sourceControlPreferencesService.validateSourceControlPreferences(
@ -115,7 +129,14 @@ export class SourceControlController {
);
}
await this.sourceControlService.init();
return this.sourceControlPreferencesService.getPreferences();
const resultingPreferences = this.sourceControlPreferencesService.getPreferences();
void Container.get(InternalHooks).onSourceControlSettingsUpdated({
branch_name: resultingPreferences.branchName,
connected: resultingPreferences.connected,
read_only_instance: resultingPreferences.branchReadOnly,
repo_type: getRepoType(resultingPreferences.repositoryUrl),
});
return resultingPreferences;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -146,18 +167,18 @@ export class SourceControlController {
async pushWorkfolder(
req: SourceControlRequest.PushWorkFolder,
res: express.Response,
): Promise<PushResult | SourceControlledFile[]> {
): Promise<SourceControlledFile[]> {
if (this.sourceControlPreferencesService.isBranchReadOnly()) {
throw new BadRequestError('Cannot push onto read-only branch.');
}
try {
await this.sourceControlService.setGitUserDetails(
`${req.user.firstName} ${req.user.lastName}`,
req.user.email,
);
const result = await this.sourceControlService.pushWorkfolder(req.body);
if ((result as PushResult).pushed) {
res.statusCode = 200;
} else {
res.statusCode = 409;
}
return result;
res.statusCode = result.statusCode;
return result.statusResult;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -168,20 +189,15 @@ export class SourceControlController {
async pullWorkfolder(
req: SourceControlRequest.PullWorkFolder,
res: express.Response,
): Promise<SourceControlledFile[] | ImportResult | PullResult | StatusResult | undefined> {
): Promise<SourceControlledFile[] | ImportResult | PullResult | undefined> {
try {
const result = await this.sourceControlService.pullWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
importAfterPull: req.body.importAfterPull ?? true,
});
if ((result as ImportResult)?.workflows) {
res.statusCode = 200;
} else {
res.statusCode = 409;
}
return result;
res.statusCode = result.statusCode;
return result.statusResult;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -189,16 +205,9 @@ export class SourceControlController {
@Authorized(['global', 'owner'])
@Get('/reset-workfolder', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async resetWorkfolder(
req: SourceControlRequest.PullWorkFolder,
): Promise<ImportResult | undefined> {
async resetWorkfolder(): Promise<ImportResult | undefined> {
try {
return await this.sourceControlService.resetWorkfolder({
force: req.body.force,
variables: req.body.variables,
userId: req.user.id,
importAfterPull: req.body.importAfterPull ?? true,
});
return await this.sourceControlService.resetWorkfolder();
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -206,9 +215,12 @@ export class SourceControlController {
@Authorized('any')
@Get('/get-status', { middlewares: [sourceControlLicensedAndEnabledMiddleware] })
async getStatus() {
async getStatus(req: SourceControlRequest.GetStatus) {
try {
return await this.sourceControlService.getStatus();
const result = (await this.sourceControlService.getStatus(
new SourceControlGetStatus(req.query),
)) as SourceControlledFile[];
return result;
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}
@ -216,9 +228,9 @@ export class SourceControlController {
@Authorized('any')
@Get('/status', { middlewares: [sourceControlLicensedMiddleware] })
async status(): Promise<StatusResult> {
async status(req: SourceControlRequest.GetStatus) {
try {
return await this.sourceControlService.status();
return await this.sourceControlService.getStatus(new SourceControlGetStatus(req.query));
} catch (error) {
throw new BadRequestError((error as { message: string }).message);
}

View file

@ -3,23 +3,28 @@ import path from 'path';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_OWNERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
} from './constants';
import * as Db from '@/Db';
import glob from 'fast-glob';
import type { ICredentialDataDecryptedObject } from 'n8n-workflow';
import { LoggerProxy, jsonParse } from 'n8n-workflow';
import { writeFile as fsWriteFile, readFile as fsReadFile, rm as fsRm } from 'fs/promises';
import { LoggerProxy } from 'n8n-workflow';
import { writeFile as fsWriteFile, rm as fsRm } from 'fs/promises';
import { rmSync } from 'fs';
import { Credentials, UserSettings } from 'n8n-core';
import type { IWorkflowToImport } from '@/Interfaces';
import type { ExportableWorkflow } from './types/exportableWorkflow';
import type { ExportableCredential } from './types/exportableCredential';
import type { ExportResult } from './types/exportResult';
import type { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee';
import {
getCredentialExportPath,
getVariablesPath,
getWorkflowExportPath,
sourceControlFoldersExistCheck,
stringContainsExpression,
} from './sourceControlHelper.ee';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { In } from 'typeorm';
import type { SourceControlledFile } from './types/sourceControlledFile';
@Service()
export class SourceControlExportService {
@ -40,79 +45,11 @@ export class SourceControlExportService {
}
getWorkflowPath(workflowId: string): string {
return path.join(this.workflowExportFolder, `${workflowId}.json`);
return getWorkflowExportPath(workflowId, this.workflowExportFolder);
}
getCredentialsPath(credentialsId: string): string {
return path.join(this.credentialExportFolder, `${credentialsId}.json`);
}
getTagsPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
}
getOwnersPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_OWNERS_EXPORT_FILE);
}
getVariablesPath(): string {
return path.join(this.gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE);
}
async getWorkflowFromFile(
filePath: string,
root = this.gitFolder,
): Promise<IWorkflowToImport | undefined> {
try {
const importedWorkflow = jsonParse<IWorkflowToImport>(
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
);
return importedWorkflow;
} catch (error) {
return undefined;
}
}
async getCredentialFromFile(
filePath: string,
root = this.gitFolder,
): Promise<ExportableCredential | undefined> {
try {
const credential = jsonParse<ExportableCredential>(
await fsReadFile(path.join(root, filePath), { encoding: 'utf8' }),
);
return credential;
} catch (error) {
return undefined;
}
}
async cleanWorkFolder() {
try {
const workflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
});
const credentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
});
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
await Promise.all(tagsFile.map(async (e) => fsRm(e)));
await Promise.all(variablesFile.map(async (e) => fsRm(e)));
await Promise.all(workflowFiles.map(async (e) => fsRm(e)));
await Promise.all(credentialFiles.map(async (e) => fsRm(e)));
LoggerProxy.debug('Cleaned work folder.');
} catch (error) {
LoggerProxy.error(`Failed to clean work folder: ${(error as Error).message}`);
}
return getCredentialExportPath(credentialsId, this.credentialExportFolder);
}
async deleteRepositoryFolder() {
@ -123,86 +60,73 @@ export class SourceControlExportService {
}
}
private async rmDeletedWorkflowsFromExportFolder(
workflowsToBeExported: SharedWorkflow[],
): Promise<Set<string>> {
const sharedWorkflowsFileNames = new Set<string>(
workflowsToBeExported.map((e) => this.getWorkflowPath(e?.workflow?.name)),
);
const existingWorkflowsInFolder = new Set<string>(
await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
}),
);
const deletedWorkflows = new Set(existingWorkflowsInFolder);
for (const elem of sharedWorkflowsFileNames) {
deletedWorkflows.delete(elem);
}
public rmFilesFromExportFolder(filesToBeDeleted: Set<string>): Set<string> {
try {
await Promise.all([...deletedWorkflows].map(async (e) => fsRm(e)));
filesToBeDeleted.forEach((e) => rmSync(e));
} catch (error) {
LoggerProxy.error(`Failed to delete workflows from work folder: ${(error as Error).message}`);
}
return deletedWorkflows;
return filesToBeDeleted;
}
private async writeExportableWorkflowsToExportFolder(workflowsToBeExported: SharedWorkflow[]) {
private async writeExportableWorkflowsToExportFolder(
workflowsToBeExported: WorkflowEntity[],
owners: Record<string, string>,
) {
await Promise.all(
workflowsToBeExported.map(async (e) => {
if (!e.workflow) {
LoggerProxy.debug(
`Found no corresponding workflow ${e.workflowId ?? 'unknown'}, skipping export`,
);
return;
}
const fileName = this.getWorkflowPath(e.workflow?.id);
const fileName = this.getWorkflowPath(e.id);
const sanitizedWorkflow: ExportableWorkflow = {
active: e.workflow?.active,
id: e.workflow?.id,
name: e.workflow?.name,
nodes: e.workflow?.nodes,
connections: e.workflow?.connections,
settings: e.workflow?.settings,
triggerCount: e.workflow?.triggerCount,
versionId: e.workflow?.versionId,
id: e.id,
name: e.name,
nodes: e.nodes,
connections: e.connections,
settings: e.settings,
triggerCount: e.triggerCount,
versionId: e.versionId,
owner: owners[e.id],
};
LoggerProxy.debug(`Writing workflow ${e.workflowId} to ${fileName}`);
LoggerProxy.debug(`Writing workflow ${e.id} to ${fileName}`);
return fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2));
}),
);
}
async exportWorkflowsToWorkFolder(): Promise<ExportResult> {
async exportWorkflowsToWorkFolder(candidates: SourceControlledFile[]): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.workflowExportFolder]);
const workflowIds = candidates.map((e) => e.id);
const sharedWorkflows = await Db.collections.SharedWorkflow.find({
relations: ['workflow', 'role', 'user'],
relations: ['role', 'user'],
where: {
role: {
name: 'owner',
scope: 'workflow',
},
workflowId: In(workflowIds),
},
});
const workflows = await Db.collections.Workflow.find({
where: {
id: In(workflowIds),
},
});
// before exporting, figure out which workflows have been deleted and remove them from the export folder
const removedFiles = await this.rmDeletedWorkflowsFromExportFolder(sharedWorkflows);
// write the workflows to the export folder as json files
await this.writeExportableWorkflowsToExportFolder(sharedWorkflows);
// write list of owners to file
const ownersFileName = this.getOwnersPath();
// determine owner of each workflow to be exported
const owners: Record<string, string> = {};
sharedWorkflows.forEach((e) => (owners[e.workflowId] = e.user.email));
await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2));
// write the workflows to the export folder as json files
await this.writeExportableWorkflowsToExportFolder(workflows, owners);
// await fsWriteFile(ownersFileName, JSON.stringify(owners, null, 2));
return {
count: sharedWorkflows.length,
folder: this.workflowExportFolder,
files: sharedWorkflows.map((e) => ({
id: e?.workflow?.id,
name: this.getWorkflowPath(e?.workflow?.name),
files: workflows.map((e) => ({
id: e?.id,
name: this.getWorkflowPath(e?.name),
})),
removedFiles: [...removedFiles],
};
} catch (error) {
throw Error(`Failed to export workflows to work folder: ${(error as Error).message}`);
@ -221,7 +145,7 @@ export class SourceControlExportService {
files: [],
};
}
const fileName = this.getVariablesPath();
const fileName = getVariablesPath(this.gitFolder);
const sanitizedVariables = variables.map((e) => ({ ...e, value: '' }));
await fsWriteFile(fileName, JSON.stringify(sanitizedVariables, null, 2));
return {
@ -252,7 +176,7 @@ export class SourceControlExportService {
};
}
const mappings = await Db.collections.WorkflowTagMapping.find();
const fileName = this.getTagsPath();
const fileName = path.join(this.gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
await fsWriteFile(
fileName,
JSON.stringify(
@ -289,10 +213,7 @@ export class SourceControlExportService {
} else if (typeof data[key] === 'object') {
data[key] = this.replaceCredentialData(data[key] as ICredentialDataDecryptedObject);
} else if (typeof data[key] === 'string') {
data[key] =
(data[key] as string)?.startsWith('={{') && (data[key] as string)?.includes('$secret')
? data[key]
: '';
data[key] = stringContainsExpression(data[key] as string) ? data[key] : '';
} else if (typeof data[key] === 'number') {
// TODO: leaving numbers in for now, but maybe we should remove them
continue;
@ -305,23 +226,31 @@ export class SourceControlExportService {
return data;
};
async exportCredentialsToWorkFolder(): Promise<ExportResult> {
async exportCredentialsToWorkFolder(candidates: SourceControlledFile[]): Promise<ExportResult> {
try {
sourceControlFoldersExistCheck([this.credentialExportFolder]);
const sharedCredentials = await Db.collections.SharedCredentials.find({
const credentialIds = candidates.map((e) => e.id);
const credentialsToBeExported = await Db.collections.SharedCredentials.find({
relations: ['credentials', 'role', 'user'],
where: {
credentialsId: In(credentialIds),
},
});
let missingIds: string[] = [];
if (credentialsToBeExported.length !== credentialIds.length) {
const foundCredentialIds = credentialsToBeExported.map((e) => e.credentialsId);
missingIds = credentialIds.filter(
(remote) => foundCredentialIds.findIndex((local) => local === remote) === -1,
);
}
const encryptionKey = await UserSettings.getEncryptionKey();
await Promise.all(
sharedCredentials.map(async (sharedCredential) => {
credentialsToBeExported.map(async (sharedCredential) => {
const { name, type, nodesAccess, data, id } = sharedCredential.credentials;
const credentialObject = new Credentials({ id, name }, type, nodesAccess, data);
const plainData = credentialObject.getData(encryptionKey);
const sanitizedData = this.replaceCredentialData(plainData);
const fileName = path.join(
this.credentialExportFolder,
`${sharedCredential.credentials.id}.json`,
);
const fileName = this.getCredentialsPath(sharedCredential.credentials.id);
const sanitizedCredential: ExportableCredential = {
id: sharedCredential.credentials.id,
name: sharedCredential.credentials.name,
@ -334,12 +263,13 @@ export class SourceControlExportService {
}),
);
return {
count: sharedCredentials.length,
count: credentialsToBeExported.length,
folder: this.credentialExportFolder,
files: sharedCredentials.map((e) => ({
files: credentialsToBeExported.map((e) => ({
id: e.credentials.id,
name: path.join(this.credentialExportFolder, `${e.credentials.name}.json`),
})),
missingIds,
};
} catch (error) {
throw Error(`Failed to export credentials to work folder: ${(error as Error).message}`);

View file

@ -12,10 +12,16 @@ import type {
SimpleGitOptions,
StatusResult,
} from 'simple-git';
import { simpleGit } from 'simple-git';
import type { SourceControlPreferences } from './types/sourceControlPreferences';
import { SOURCE_CONTROL_DEFAULT_BRANCH, SOURCE_CONTROL_ORIGIN } from './constants';
import {
SOURCE_CONTROL_DEFAULT_BRANCH,
SOURCE_CONTROL_DEFAULT_EMAIL,
SOURCE_CONTROL_DEFAULT_NAME,
SOURCE_CONTROL_ORIGIN,
} from './constants';
import { sourceControlFoldersExistCheck } from './sourceControlHelper.ee';
import type { User } from '../../databases/entities/User';
import { getInstanceOwner } from '../../UserManagement/UserManagementHelper';
@Service()
export class SourceControlGitService {
@ -27,7 +33,7 @@ export class SourceControlGitService {
* Run pre-checks before initialising git
* Checks for existence of required binaries (git and ssh)
*/
preInitCheck(): boolean {
private preInitCheck(): boolean {
LoggerProxy.debug('GitService.preCheck');
try {
const gitResult = execSync('git --version', {
@ -80,6 +86,8 @@ export class SourceControlGitService {
trimmed: false,
};
const { simpleGit } = await import('simple-git');
this.git = simpleGit(this.gitOptions)
// Tell git not to ask for any information via the terminal like for
// example the username. As nobody will be able to answer it would
@ -92,7 +100,8 @@ export class SourceControlGitService {
}
if (!(await this.hasRemote(sourceControlPreferences.repositoryUrl))) {
if (sourceControlPreferences.connected && sourceControlPreferences.repositoryUrl) {
await this.initRepository(sourceControlPreferences);
const user = await getInstanceOwner();
await this.initRepository(sourceControlPreferences, user);
}
}
}
@ -101,9 +110,9 @@ export class SourceControlGitService {
this.git = null;
}
async checkRepositorySetup(): Promise<boolean> {
private async checkRepositorySetup(): Promise<boolean> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (async)');
}
if (!(await this.git.checkIsRepo())) {
return false;
@ -116,9 +125,9 @@ export class SourceControlGitService {
}
}
async hasRemote(remote: string): Promise<boolean> {
private async hasRemote(remote: string): Promise<boolean> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (async)');
}
try {
const remotes = await this.git.getRemotes(true);
@ -139,11 +148,12 @@ export class SourceControlGitService {
async initRepository(
sourceControlPreferences: Pick<
SourceControlPreferences,
'repositoryUrl' | 'authorEmail' | 'authorName' | 'branchName' | 'initRepo'
'repositoryUrl' | 'branchName' | 'initRepo'
>,
user: User,
): Promise<void> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (Promise)');
}
if (sourceControlPreferences.initRepo) {
try {
@ -161,8 +171,10 @@ export class SourceControlGitService {
throw error;
}
}
await this.git.addConfig('user.email', sourceControlPreferences.authorEmail);
await this.git.addConfig('user.name', sourceControlPreferences.authorName);
await this.setGitUserDetails(
`${user.firstName} ${user.lastName}` ?? SOURCE_CONTROL_DEFAULT_NAME,
user.email ?? SOURCE_CONTROL_DEFAULT_EMAIL,
);
if (sourceControlPreferences.initRepo) {
try {
const branches = await this.getBranches();
@ -175,9 +187,17 @@ export class SourceControlGitService {
}
}
async setGitUserDetails(name: string, email: string): Promise<void> {
if (!this.git) {
throw new Error('Git is not initialized (setGitUserDetails)');
}
await this.git.addConfig('user.email', name);
await this.git.addConfig('user.name', email);
}
async getBranches(): Promise<{ branches: string[]; currentBranch: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (getBranches)');
}
try {
@ -200,23 +220,16 @@ export class SourceControlGitService {
async setBranch(branch: string): Promise<{ branches: string[]; currentBranch: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (setBranch)');
}
await this.git.checkout(branch);
await this.git.branch([`--set-upstream-to=${SOURCE_CONTROL_ORIGIN}/${branch}`, branch]);
return this.getBranches();
}
async fetch(): Promise<FetchResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
return this.git.fetch();
}
async getCurrentBranch(): Promise<{ current: string; remote: string }> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (getCurrentBranch)');
}
const currentBranch = (await this.git.branch()).current;
return {
@ -225,49 +238,47 @@ export class SourceControlGitService {
};
}
async diff(options?: { target?: string; dots?: '..' | '...' }): Promise<DiffResult> {
if (!this.git) {
throw new Error('Git is not initialized');
}
const currentBranch = await this.getCurrentBranch();
const target = options?.target ?? currentBranch.remote;
const dots = options?.dots ?? '...';
return this.git.diffSummary([dots + target]);
}
async diffRemote(): Promise<DiffResult | undefined> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (diffRemote)');
}
const currentBranch = await this.getCurrentBranch();
if (currentBranch.remote) {
const target = currentBranch.remote;
return this.git.diffSummary(['...' + target]);
return this.git.diffSummary(['...' + target, '--ignore-all-space']);
}
return;
}
async diffLocal(): Promise<DiffResult | undefined> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (diffLocal)');
}
const currentBranch = await this.getCurrentBranch();
if (currentBranch.remote) {
const target = currentBranch.current;
return this.git.diffSummary([target]);
return this.git.diffSummary([target, '--ignore-all-space']);
}
return;
}
async fetch(): Promise<FetchResult> {
if (!this.git) {
throw new Error('Git is not initialized (fetch)');
}
return this.git.fetch();
}
async pull(options: { ffOnly: boolean } = { ffOnly: true }): Promise<PullResult> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (pull)');
}
const params = {};
if (options.ffOnly) {
// eslint-disable-next-line @typescript-eslint/naming-convention
return this.git.pull(undefined, undefined, { '--ff-only': null });
Object.assign(params, { '--ff-only': true });
}
return this.git.pull();
return this.git.pull(params);
}
async push(
@ -278,7 +289,7 @@ export class SourceControlGitService {
): Promise<PushResult> {
const { force, branch } = options;
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized ({)');
}
if (force) {
return this.git.push(SOURCE_CONTROL_ORIGIN, branch, ['-f']);
@ -288,7 +299,7 @@ export class SourceControlGitService {
async stage(files: Set<string>, deletedFiles?: Set<string>): Promise<string> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (stage)');
}
if (deletedFiles?.size) {
try {
@ -301,10 +312,10 @@ export class SourceControlGitService {
}
async resetBranch(
options: { hard?: boolean; target: string } = { hard: false, target: 'HEAD' },
options: { hard: boolean; target: string } = { hard: true, target: 'HEAD' },
): Promise<string> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (Promise)');
}
if (options?.hard) {
return this.git.raw(['reset', '--hard', options.target]);
@ -316,14 +327,14 @@ export class SourceControlGitService {
async commit(message: string): Promise<CommitResult> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (commit)');
}
return this.git.commit(message);
}
async status(): Promise<StatusResult> {
if (!this.git) {
throw new Error('Git is not initialized');
throw new Error('Git is not initialized (status)');
}
const statusResult = await this.git.status();
return statusResult;

View file

@ -1,25 +1,61 @@
import { Container } from 'typedi';
import Container from 'typedi';
import { License } from '@/License';
import { generateKeyPairSync } from 'crypto';
import sshpk from 'sshpk';
import type { KeyPair } from './types/keyPair';
import { constants as fsConstants, mkdirSync, accessSync } from 'fs';
import { LoggerProxy } from 'n8n-workflow';
import { License } from '@/License';
import type { KeyPair } from './types/keyPair';
import { SOURCE_CONTROL_GIT_KEY_COMMENT } from './constants';
import {
SOURCE_CONTROL_GIT_KEY_COMMENT,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
} from './constants';
import type { SourceControlledFile } from './types/sourceControlledFile';
import path from 'path';
export function sourceControlFoldersExistCheck(folders: string[]) {
export function stringContainsExpression(testString: string): boolean {
return /^=.*\{\{.*\}\}/.test(testString);
}
export function getWorkflowExportPath(workflowId: string, workflowExportFolder: string): string {
return path.join(workflowExportFolder, `${workflowId}.json`);
}
export function getCredentialExportPath(
credentialId: string,
credentialExportFolder: string,
): string {
return path.join(credentialExportFolder, `${credentialId}.json`);
}
export function getVariablesPath(gitFolder: string): string {
return path.join(gitFolder, SOURCE_CONTROL_VARIABLES_EXPORT_FILE);
}
export function getTagsPath(gitFolder: string): string {
return path.join(gitFolder, SOURCE_CONTROL_TAGS_EXPORT_FILE);
}
export function sourceControlFoldersExistCheck(
folders: string[],
createIfNotExists = true,
): boolean {
// running these file access function synchronously to avoid race conditions
let existed = true;
folders.forEach((folder) => {
try {
accessSync(folder, fsConstants.F_OK);
} catch {
try {
mkdirSync(folder);
} catch (error) {
LoggerProxy.error((error as Error).message);
existed = false;
if (createIfNotExists) {
try {
mkdirSync(folder, { recursive: true });
} catch (error) {
LoggerProxy.error((error as Error).message);
}
}
}
});
return existed;
}
export function isSourceControlLicensed() {
@ -27,7 +63,8 @@ export function isSourceControlLicensed() {
return license.isSourceControlLicensed();
}
export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
export async function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
const sshpk = await import('sshpk');
const keyPair: KeyPair = {
publicKey: '',
privateKey: '',
@ -65,3 +102,76 @@ export function generateSshKeyPair(keyType: 'ed25519' | 'rsa' = 'ed25519') {
publicKey: keyPair.publicKey,
};
}
export function getRepoType(repoUrl: string): 'github' | 'gitlab' | 'other' {
if (repoUrl.includes('github.com')) {
return 'github';
} else if (repoUrl.includes('gitlab.com')) {
return 'gitlab';
}
return 'other';
}
function filterSourceControlledFilesUniqueIds(files: SourceControlledFile[]) {
return (
files.filter((file, index, self) => {
return self.findIndex((f) => f.id === file.id) === index;
}) || []
);
}
export function getTrackingInformationFromPullResult(result: SourceControlledFile[]): {
cred_conflicts: number;
workflow_conflicts: number;
workflow_updates: number;
} {
const uniques = filterSourceControlledFilesUniqueIds(result);
return {
cred_conflicts: uniques.filter(
(file) =>
file.type === 'credential' && file.status === 'modified' && file.location === 'local',
).length,
workflow_conflicts: uniques.filter(
(file) => file.type === 'workflow' && file.status === 'modified' && file.location === 'local',
).length,
workflow_updates: uniques.filter((file) => file.type === 'workflow').length,
};
}
export function getTrackingInformationFromPrePushResult(result: SourceControlledFile[]): {
workflows_eligible: number;
workflows_eligible_with_conflicts: number;
creds_eligible: number;
creds_eligible_with_conflicts: number;
variables_eligible: number;
} {
const uniques = filterSourceControlledFilesUniqueIds(result);
return {
workflows_eligible: uniques.filter((file) => file.type === 'workflow').length,
workflows_eligible_with_conflicts: uniques.filter(
(file) => file.type === 'workflow' && file.conflict,
).length,
creds_eligible: uniques.filter((file) => file.type === 'credential').length,
creds_eligible_with_conflicts: uniques.filter(
(file) => file.type === 'credential' && file.conflict,
).length,
variables_eligible: uniques.filter((file) => file.type === 'variables').length,
};
}
export function getTrackingInformationFromPostPushResult(result: SourceControlledFile[]): {
workflows_eligible: number;
workflows_pushed: number;
creds_pushed: number;
variables_pushed: number;
} {
const uniques = filterSourceControlledFilesUniqueIds(result);
return {
workflows_pushed: uniques.filter((file) => file.pushed && file.type === 'workflow').length ?? 0,
workflows_eligible: uniques.filter((file) => file.type === 'workflow').length ?? 0,
creds_pushed:
uniques.filter((file) => file.pushed && file.file.startsWith('credential_stubs')).length ?? 0,
variables_pushed:
uniques.filter((file) => file.pushed && file.file.startsWith('variable_stubs')).length ?? 0,
};
}

View file

@ -3,7 +3,6 @@ import path from 'path';
import {
SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_OWNERS_EXPORT_FILE,
SOURCE_CONTROL_TAGS_EXPORT_FILE,
SOURCE_CONTROL_VARIABLES_EXPORT_FILE,
SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER,
@ -16,15 +15,16 @@ import { Credentials, UserSettings } from 'n8n-core';
import type { IWorkflowToImport } from '@/Interfaces';
import type { ExportableCredential } from './types/exportableCredential';
import { Variables } from '@db/entities/Variables';
import type { ImportResult } from './types/importResult';
import { UM_FIX_INSTRUCTION } from '@/commands/BaseCommand';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import type { WorkflowTagMapping } from '@db/entities/WorkflowTagMapping';
import type { TagEntity } from '@db/entities/TagEntity';
import { ActiveWorkflowRunner } from '../../ActiveWorkflowRunner';
import type { SourceControllPullOptions } from './types/sourceControlPullWorkFolder';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { In } from 'typeorm';
import { isUniqueConstraintError } from '../../ResponseHelper';
import { isUniqueConstraintError } from '@/ResponseHelper';
import type { SourceControlWorkflowVersionId } from './types/sourceControlWorkflowVersionId';
import { getCredentialExportPath, getWorkflowExportPath } from './sourceControlHelper.ee';
import type { SourceControlledFile } from './types/sourceControlledFile';
@Service()
export class SourceControlImportService {
@ -143,65 +143,113 @@ export class SourceControlImportService {
return importCredentialsResult.filter((e) => e !== undefined);
}
private async importVariablesFromFile(valueOverrides?: {
[key: string]: string;
}): Promise<{ imported: string[] }> {
public async getRemoteVersionIdsFromFiles(): Promise<SourceControlWorkflowVersionId[]> {
const remoteWorkflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
});
const remoteWorkflowFilesParsed = await Promise.all(
remoteWorkflowFiles.map(async (file) => {
LoggerProxy.debug(`Parsing workflow file ${file}`);
const remote = jsonParse<IWorkflowToImport>(await fsReadFile(file, { encoding: 'utf8' }));
if (!remote?.id) {
return undefined;
}
return {
id: remote.id,
versionId: remote.versionId,
name: remote.name,
remoteId: remote.id,
filename: getWorkflowExportPath(remote.id, this.workflowExportFolder),
} as SourceControlWorkflowVersionId;
}),
);
return remoteWorkflowFilesParsed.filter(
(e) => e !== undefined,
) as SourceControlWorkflowVersionId[];
}
public async getLocalVersionIdsFromDb(): Promise<SourceControlWorkflowVersionId[]> {
const localWorkflows = await Db.collections.Workflow.find({
select: ['id', 'name', 'versionId', 'updatedAt'],
});
return localWorkflows.map((local) => ({
id: local.id,
versionId: local.versionId,
name: local.name,
localId: local.id,
filename: getWorkflowExportPath(local.id, this.workflowExportFolder),
updatedAt: local.updatedAt.toISOString(),
})) as SourceControlWorkflowVersionId[];
}
public async getRemoteCredentialsFromFiles(): Promise<
Array<ExportableCredential & { filename: string }>
> {
const remoteCredentialFiles = await glob('*.json', {
cwd: this.credentialExportFolder,
absolute: true,
});
const remoteCredentialFilesParsed = await Promise.all(
remoteCredentialFiles.map(async (file) => {
LoggerProxy.debug(`Parsing credential file ${file}`);
const remote = jsonParse<ExportableCredential>(
await fsReadFile(file, { encoding: 'utf8' }),
);
if (!remote?.id) {
return undefined;
}
return {
...remote,
filename: getCredentialExportPath(remote.id, this.credentialExportFolder),
};
}),
);
return remoteCredentialFilesParsed.filter((e) => e !== undefined) as Array<
ExportableCredential & { filename: string }
>;
}
public async getLocalCredentialsFromDb(): Promise<
Array<ExportableCredential & { filename: string }>
> {
const localCredentials = await Db.collections.Credentials.find({
select: ['id', 'name', 'type', 'nodesAccess'],
});
return localCredentials.map((local) => ({
id: local.id,
name: local.name,
type: local.type,
nodesAccess: local.nodesAccess,
filename: getCredentialExportPath(local.id, this.credentialExportFolder),
})) as Array<ExportableCredential & { filename: string }>;
}
public async getRemoteVariablesFromFile(): Promise<Variables[]> {
const variablesFile = await glob(SOURCE_CONTROL_VARIABLES_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
});
const result: { imported: string[] } = { imported: [] };
if (variablesFile.length > 0) {
LoggerProxy.debug(`Importing variables from file ${variablesFile[0]}`);
const importedVariables = jsonParse<Array<Partial<Variables>>>(
await fsReadFile(variablesFile[0], { encoding: 'utf8' }),
{ fallbackValue: [] },
);
const overriddenKeys = Object.keys(valueOverrides ?? {});
for (const variable of importedVariables) {
if (!variable.key) {
continue;
}
// by default no value is stored remotely, so an empty string is retuned
// it must be changed to undefined so as to not overwrite existing values!
if (variable.value === '') {
variable.value = undefined;
}
if (overriddenKeys.includes(variable.key) && valueOverrides) {
variable.value = valueOverrides[variable.key];
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
}
try {
await Db.collections.Variables.upsert({ ...variable }, ['id']);
} catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) {
LoggerProxy.debug(`Variable ${variable.key} already exists, updating instead`);
try {
await Db.collections.Variables.update({ key: variable.key }, { ...variable });
} catch (errorUpdate) {
LoggerProxy.debug(`Failed to update variable ${variable.key}, skipping`);
LoggerProxy.debug((errorUpdate as Error).message);
}
}
} finally {
result.imported.push(variable.key);
}
}
// add remaining overrides as new variables
if (overriddenKeys.length > 0 && valueOverrides) {
for (const key of overriddenKeys) {
result.imported.push(key);
const newVariable = new Variables({ key, value: valueOverrides[key] });
await Db.collections.Variables.save(newVariable);
}
}
return jsonParse<Variables[]>(await fsReadFile(variablesFile[0], { encoding: 'utf8' }), {
fallbackValue: [],
});
}
return result;
return [];
}
private async importTagsFromFile() {
public async getLocalVariablesFromDb(): Promise<Variables[]> {
const localVariables = await Db.collections.Variables.find({
select: ['id', 'key', 'type', 'value'],
});
return localVariables;
}
public async getRemoteTagsAndMappingsFromFile(): Promise<{
tags: TagEntity[];
mappings: WorkflowTagMapping[];
}> {
const tagsFile = await glob(SOURCE_CONTROL_TAGS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
@ -212,110 +260,51 @@ export class SourceControlImportService {
await fsReadFile(tagsFile[0], { encoding: 'utf8' }),
{ fallbackValue: { tags: [], mappings: [] } },
);
const existingWorkflowIds = new Set(
(
await Db.collections.Workflow.find({
select: ['id'],
})
).map((e) => e.id),
);
await Promise.all(
mappedTags.tags.map(async (tag) => {
await Db.collections.Tag.upsert(
{
...tag,
},
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { id: true },
},
);
}),
);
await Promise.all(
mappedTags.mappings.map(async (mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
await Db.collections.WorkflowTagMapping.upsert(
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { tagId: true, workflowId: true },
},
);
}),
);
return mappedTags;
}
return { tags: [], mappings: [] };
}
private async importWorkflowsFromFiles(
userId: string,
): Promise<Array<{ id: string; name: string }>> {
const workflowFiles = await glob('*.json', {
cwd: this.workflowExportFolder,
absolute: true,
public async getLocalTagsAndMappingsFromDb(): Promise<{
tags: TagEntity[];
mappings: WorkflowTagMapping[];
}> {
const localTags = await Db.collections.Tag.find({
select: ['id', 'name'],
});
const existingWorkflows = await Db.collections.Workflow.find({
select: ['id', 'name', 'active', 'versionId'],
const localMappings = await Db.collections.WorkflowTagMapping.find({
select: ['workflowId', 'tagId'],
});
return { tags: localTags, mappings: localMappings };
}
public async importWorkflowFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const ownerWorkflowRole = await this.getOwnerWorkflowRole();
const workflowRunner = Container.get(ActiveWorkflowRunner);
// read owner file if it exists and map workflow ids to owner emails
// then find existing users with those emails or fallback to passed in userId
const ownerRecords: Record<string, string> = {};
const ownersFile = await glob(SOURCE_CONTROL_OWNERS_EXPORT_FILE, {
cwd: this.gitFolder,
absolute: true,
const candidateIds = candidates.map((c) => c.id);
const existingWorkflows = await Db.collections.Workflow.find({
where: {
id: In(candidateIds),
},
select: ['id', 'name', 'versionId', 'active'],
});
if (ownersFile.length > 0) {
LoggerProxy.debug(`Reading workflow owners from file ${ownersFile[0]}`);
const ownerEmails = jsonParse<Record<string, string>>(
await fsReadFile(ownersFile[0], { encoding: 'utf8' }),
{ fallbackValue: {} },
);
if (ownerEmails) {
const uniqueOwnerEmails = new Set(Object.values(ownerEmails));
const existingUsers = await Db.collections.User.find({
where: { email: In([...uniqueOwnerEmails]) },
});
Object.keys(ownerEmails).forEach((workflowId) => {
ownerRecords[workflowId] =
existingUsers.find((e) => e.email === ownerEmails[workflowId])?.id ?? userId;
});
}
}
let importWorkflowsResult = new Array<{ id: string; name: string } | undefined>();
const allSharedWorkflows = await Db.collections.SharedWorkflow.find({
where: {
workflowId: In(candidateIds),
},
select: ['workflowId', 'roleId', 'userId'],
});
importWorkflowsResult = await Promise.all(
workflowFiles.map(async (file) => {
LoggerProxy.debug(`Parsing workflow file ${file}`);
const importedWorkflow = jsonParse<IWorkflowToImport>(
await fsReadFile(file, { encoding: 'utf8' }),
const cachedOwnerIds = new Map<string, string>();
const importWorkflowsResult = await Promise.all(
candidates.map(async (candidate) => {
LoggerProxy.debug(`Parsing workflow file ${candidate.file}`);
const importedWorkflow = jsonParse<IWorkflowToImport & { owner: string }>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
);
if (!importedWorkflow?.id) {
return;
}
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
if (existingWorkflow?.versionId === importedWorkflow.versionId) {
LoggerProxy.debug(
`Skipping import of workflow ${importedWorkflow.id ?? 'n/a'} - versionId is up to date`,
);
return {
id: importedWorkflow.id ?? 'n/a',
name: 'skipped',
};
}
LoggerProxy.debug(`Importing workflow ${importedWorkflow.id ?? 'n/a'}`);
importedWorkflow.active = existingWorkflow?.active ?? false;
LoggerProxy.debug(`Updating workflow id ${importedWorkflow.id ?? 'new'}`);
const upsertResult = await Db.collections.Workflow.upsert({ ...importedWorkflow }, ['id']);
@ -324,12 +313,31 @@ export class SourceControlImportService {
}
// Update workflow owner to the user who exported the workflow, if that user exists
// in the instance, and the workflow doesn't already have an owner
const workflowOwnerId = ownerRecords[importedWorkflow.id] ?? userId;
let workflowOwnerId = userId;
if (cachedOwnerIds.has(importedWorkflow.owner)) {
workflowOwnerId = cachedOwnerIds.get(importedWorkflow.owner) ?? userId;
} else {
const foundUser = await Db.collections.User.findOne({
where: {
email: importedWorkflow.owner,
},
select: ['id'],
});
if (foundUser) {
cachedOwnerIds.set(importedWorkflow.owner, foundUser.id);
workflowOwnerId = foundUser.id;
}
}
const existingSharedWorkflowOwnerByRoleId = allSharedWorkflows.find(
(e) => e.workflowId === importedWorkflow.id && e.roleId === ownerWorkflowRole.id,
(e) =>
e.workflowId === importedWorkflow.id &&
e.roleId.toString() === ownerWorkflowRole.id.toString(),
);
const existingSharedWorkflowOwnerByUserId = allSharedWorkflows.find(
(e) => e.workflowId === importedWorkflow.id && e.userId === workflowOwnerId,
(e) =>
e.workflowId === importedWorkflow.id &&
e.roleId.toString() === workflowOwnerId.toString(),
);
if (!existingSharedWorkflowOwnerByUserId && !existingSharedWorkflowOwnerByRoleId) {
// no owner exists yet, so create one
@ -361,39 +369,218 @@ export class SourceControlImportService {
// try activating the imported workflow
LoggerProxy.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowRunner.add(existingWorkflow.id, 'activate');
// update the versionId of the workflow to match the imported workflow
} catch (error) {
LoggerProxy.error(`Failed to activate workflow ${existingWorkflow.id}`, error as Error);
} finally {
await Db.collections.Workflow.update(
{ id: existingWorkflow.id },
{ versionId: importedWorkflow.versionId },
);
}
}
return {
id: importedWorkflow.id ?? 'unknown',
name: file,
name: candidate.file,
};
}),
);
return importWorkflowsResult.filter((e) => e !== undefined) as Array<{
id: string;
name: string;
}>;
}
async importFromWorkFolder(options: SourceControllPullOptions): Promise<ImportResult> {
try {
const importedVariables = await this.importVariablesFromFile(options.variables);
const importedCredentials = await this.importCredentialsFromFiles(options.userId);
const importWorkflows = await this.importWorkflowsFromFiles(options.userId);
const importTags = await this.importTagsFromFile();
public async importCredentialsFromWorkFolder(candidates: SourceControlledFile[], userId: string) {
const candidateIds = candidates.map((c) => c.id);
const existingCredentials = await Db.collections.Credentials.find({
where: {
id: In(candidateIds),
},
select: ['id', 'name', 'type', 'data'],
});
const ownerCredentialRole = await this.getOwnerCredentialRole();
const ownerGlobalRole = await this.getOwnerGlobalRole();
const existingSharedCredentials = await Db.collections.SharedCredentials.find({
select: ['userId', 'credentialsId', 'roleId'],
where: {
credentialsId: In(candidateIds),
roleId: In([ownerCredentialRole.id, ownerGlobalRole.id]),
},
});
const encryptionKey = await UserSettings.getEncryptionKey();
let importCredentialsResult: Array<{ id: string; name: string; type: string }> = [];
importCredentialsResult = await Promise.all(
candidates.map(async (candidate) => {
LoggerProxy.debug(`Importing credentials file ${candidate.file}`);
const credential = jsonParse<ExportableCredential>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
);
const existingCredential = existingCredentials.find(
(e) => e.id === credential.id && e.type === credential.type,
);
const sharedOwner = existingSharedCredentials.find(
(e) => e.credentialsId === credential.id,
);
return {
variables: importedVariables,
credentials: importedCredentials,
workflows: importWorkflows,
tags: importTags,
};
const { name, type, data, id, nodesAccess } = credential;
const newCredentialObject = new Credentials({ id, name }, type, []);
if (existingCredential?.data) {
newCredentialObject.data = existingCredential.data;
} else {
newCredentialObject.setData(data, encryptionKey);
}
newCredentialObject.nodesAccess = nodesAccess || existingCredential?.nodesAccess || [];
LoggerProxy.debug(`Updating credential id ${newCredentialObject.id as string}`);
await Db.collections.Credentials.upsert(newCredentialObject, ['id']);
if (!sharedOwner) {
const newSharedCredential = new SharedCredentials();
newSharedCredential.credentialsId = newCredentialObject.id as string;
newSharedCredential.userId = userId;
newSharedCredential.roleId = ownerCredentialRole.id;
await Db.collections.SharedCredentials.upsert({ ...newSharedCredential }, [
'credentialsId',
'userId',
]);
}
return {
id: newCredentialObject.id as string,
name: newCredentialObject.name,
type: newCredentialObject.type,
};
}),
);
return importCredentialsResult.filter((e) => e !== undefined);
}
public async importTagsFromWorkFolder(candidate: SourceControlledFile) {
let mappedTags;
try {
LoggerProxy.debug(`Importing tags from file ${candidate.file}`);
mappedTags = jsonParse<{ tags: TagEntity[]; mappings: WorkflowTagMapping[] }>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
{ fallbackValue: { tags: [], mappings: [] } },
);
} catch (error) {
throw Error(`Failed to import workflows from work folder: ${(error as Error).message}`);
LoggerProxy.error(`Failed to import tags from file ${candidate.file}`, error as Error);
return;
}
if (mappedTags.mappings.length === 0 && mappedTags.tags.length === 0) {
return;
}
const existingWorkflowIds = new Set(
(
await Db.collections.Workflow.find({
select: ['id'],
})
).map((e) => e.id),
);
await Promise.all(
mappedTags.tags.map(async (tag) => {
const findByName = await Db.collections.Tag.findOne({
where: { name: tag.name },
select: ['id'],
});
if (findByName && findByName.id !== tag.id) {
throw new Error(
`A tag with the name <strong>${tag.name}</strong> already exists locally.<br />Please either rename the local tag, or the remote one with the id <strong>${tag.id}</strong> in the tags.json file.`,
);
}
await Db.collections.Tag.upsert(
{
...tag,
},
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { id: true },
},
);
}),
);
await Promise.all(
mappedTags.mappings.map(async (mapping) => {
if (!existingWorkflowIds.has(String(mapping.workflowId))) return;
await Db.collections.WorkflowTagMapping.upsert(
{ tagId: String(mapping.tagId), workflowId: String(mapping.workflowId) },
{
skipUpdateIfNoValuesChanged: true,
conflictPaths: { tagId: true, workflowId: true },
},
);
}),
);
return mappedTags;
}
public async importVariablesFromWorkFolder(
candidate: SourceControlledFile,
valueOverrides?: {
[key: string]: string;
},
) {
const result: { imported: string[] } = { imported: [] };
let importedVariables;
try {
LoggerProxy.debug(`Importing variables from file ${candidate.file}`);
importedVariables = jsonParse<Array<Partial<Variables>>>(
await fsReadFile(candidate.file, { encoding: 'utf8' }),
{ fallbackValue: [] },
);
} catch (error) {
LoggerProxy.error(`Failed to import tags from file ${candidate.file}`, error as Error);
return;
}
const overriddenKeys = Object.keys(valueOverrides ?? {});
for (const variable of importedVariables) {
if (!variable.key) {
continue;
}
// by default no value is stored remotely, so an empty string is retuned
// it must be changed to undefined so as to not overwrite existing values!
if (variable.value === '') {
variable.value = undefined;
}
if (overriddenKeys.includes(variable.key) && valueOverrides) {
variable.value = valueOverrides[variable.key];
overriddenKeys.splice(overriddenKeys.indexOf(variable.key), 1);
}
try {
await Db.collections.Variables.upsert({ ...variable }, ['id']);
} catch (errorUpsert) {
if (isUniqueConstraintError(errorUpsert as Error)) {
LoggerProxy.debug(`Variable ${variable.key} already exists, updating instead`);
try {
await Db.collections.Variables.update({ key: variable.key }, { ...variable });
} catch (errorUpdate) {
LoggerProxy.debug(`Failed to update variable ${variable.key}, skipping`);
LoggerProxy.debug((errorUpdate as Error).message);
}
}
} finally {
result.imported.push(variable.key);
}
}
// add remaining overrides as new variables
if (overriddenKeys.length > 0 && valueOverrides) {
for (const key of overriddenKeys) {
result.imported.push(key);
const newVariable = new Variables({ key, value: valueOverrides[key] });
await Db.collections.Variables.save(newVariable);
}
}
return result;
}
}

View file

@ -53,6 +53,14 @@ export class SourceControlPreferencesService {
);
}
public isSourceControlSetup() {
return (
this.isSourceControlLicensedAndEnabled() &&
this.getPreferences().repositoryUrl &&
this.getPreferences().branchName
);
}
getPublicKey(): string {
try {
return fsReadFileSync(this.sshKeyName + '.pub', { encoding: 'utf8' });
@ -80,7 +88,7 @@ export class SourceControlPreferencesService {
*/
async generateAndSaveKeyPair(): Promise<SourceControlPreferences> {
sourceControlFoldersExistCheck([this.gitFolder, this.sshFolder]);
const keyPair = generateSshKeyPair('ed25519');
const keyPair = await generateSshKeyPair('ed25519');
if (keyPair.publicKey && keyPair.privateKey) {
try {
await fsWriteFile(this.sshKeyName + '.pub', keyPair.publicKey, {

View file

@ -6,4 +6,5 @@ export interface ExportResult {
name: string;
}>;
removedFiles?: string[];
missingIds?: string[];
}

View file

@ -1,7 +1,6 @@
import type { INode, IConnections, IWorkflowSettings } from 'n8n-workflow';
export interface ExportableWorkflow {
active: boolean;
id: string;
name: string;
nodes: INode[];
@ -9,4 +8,5 @@ export interface ExportableWorkflow {
settings?: IWorkflowSettings;
triggerCount: number;
versionId: string;
owner: string;
}

View file

@ -8,6 +8,7 @@ import type { SourceControlPushWorkFolder } from './sourceControlPushWorkFolder'
import type { SourceControlPullWorkFolder } from './sourceControlPullWorkFolder';
import type { SourceControlDisconnect } from './sourceControlDisconnect';
import type { SourceControlSetReadOnly } from './sourceControlSetReadOnly';
import type { SourceControlGetStatus } from './sourceControlGetStatus';
export declare namespace SourceControlRequest {
type UpdatePreferences = AuthenticatedRequest<{}, {}, Partial<SourceControlPreferences>, {}>;
@ -19,4 +20,5 @@ export declare namespace SourceControlRequest {
type Disconnect = AuthenticatedRequest<{}, {}, SourceControlDisconnect, {}>;
type PushWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPushWorkFolder, {}>;
type PullWorkFolder = AuthenticatedRequest<{}, {}, SourceControlPullWorkFolder, {}>;
type GetStatus = AuthenticatedRequest<{}, {}, {}, SourceControlGetStatus>;
}

View file

@ -0,0 +1,32 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
function booleanFromString(value: string | boolean): boolean {
if (typeof value === 'boolean') {
return value;
}
return value === 'true';
}
export class SourceControlGetStatus {
@IsString()
@IsOptional()
direction: 'push' | 'pull';
@IsBoolean()
@IsOptional()
preferLocalVersion: boolean;
@IsBoolean()
@IsOptional()
verbose: boolean;
constructor(values: {
direction: 'push' | 'pull';
preferLocalVersion: string | boolean;
verbose: string | boolean;
}) {
this.direction = values.direction || 'push';
this.preferLocalVersion = booleanFromString(values.preferLocalVersion) || true;
this.verbose = booleanFromString(values.verbose) || false;
}
}

View file

@ -1,4 +1,4 @@
import { IsBoolean, IsEmail, IsHexColor, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsHexColor, IsOptional, IsString } from 'class-validator';
export class SourceControlPreferences {
constructor(preferences: Partial<SourceControlPreferences> | undefined = undefined) {
@ -11,12 +11,6 @@ export class SourceControlPreferences {
@IsString()
repositoryUrl: string;
@IsString()
authorName: string;
@IsEmail()
authorEmail: string;
@IsString()
branchName = 'main';
@ -45,8 +39,6 @@ export class SourceControlPreferences {
return new SourceControlPreferences({
connected: preferences.connected ?? defaultPreferences.connected,
repositoryUrl: preferences.repositoryUrl ?? defaultPreferences.repositoryUrl,
authorName: preferences.authorName ?? defaultPreferences.authorName,
authorEmail: preferences.authorEmail ?? defaultPreferences.authorEmail,
branchName: preferences.branchName ?? defaultPreferences.branchName,
branchReadOnly: preferences.branchReadOnly ?? defaultPreferences.branchReadOnly,
branchColor: preferences.branchColor ?? defaultPreferences.branchColor,

View file

@ -24,6 +24,4 @@ export class SourceControllPullOptions {
force?: boolean;
variables?: { [key: string]: string };
importAfterPull?: boolean = true;
}

View file

@ -1,4 +1,5 @@
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import type { SourceControlledFile } from './sourceControlledFile';
export class SourceControlPushWorkFolder {
@IsBoolean()
@ -6,16 +7,7 @@ export class SourceControlPushWorkFolder {
force?: boolean;
@IsString({ each: true })
@IsOptional()
fileNames?: Set<string>;
@IsString({ each: true })
@IsOptional()
workflowIds?: Set<string>;
@IsString({ each: true })
@IsOptional()
credentialIds?: Set<string>;
fileNames: SourceControlledFile[];
@IsString()
@IsOptional()

View file

@ -0,0 +1,9 @@
export interface SourceControlWorkflowVersionId {
id: string;
versionId: string;
filename: string;
name?: string;
localId?: string;
remoteId?: string;
updatedAt?: string;
}

View file

@ -5,6 +5,8 @@ export type SourceControlledFileStatus =
| 'created'
| 'renamed'
| 'conflicted'
| 'ignored'
| 'staged'
| 'unknown';
export type SourceControlledFileLocation = 'local' | 'remote';
export type SourceControlledFileType = 'credential' | 'workflow' | 'tags' | 'variables' | 'file';
@ -17,4 +19,5 @@ export type SourceControlledFile = {
location: SourceControlledFileLocation;
conflict: boolean;
updatedAt: string;
pushed?: boolean;
};

View file

@ -21,7 +21,7 @@ export class EEVariablesService extends VariablesService {
if (variable.key.replace(/[A-Za-z0-9_]/g, '').length !== 0) {
throw new VariablesValidationError('key can only contain characters A-Za-z0-9_');
}
if (variable.value.length > 255) {
if (variable.value?.length > 255) {
throw new VariablesValidationError('value cannot be longer than 255 characters');
}
}

View file

@ -10,7 +10,8 @@ import { getLogger } from '@/Logger';
import { License } from '@/License';
import { LicenseService } from '@/license/License.service';
import { N8N_VERSION } from '@/constants';
import { Service } from 'typedi';
import Container, { Service } from 'typedi';
import { SourceControlPreferencesService } from '../environments/sourceControl/sourceControlPreferences.service.ee';
type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success';
@ -105,11 +106,18 @@ export class Telemetry {
this.executionCountsBuffer = {};
const sourceControlPreferences = Container.get(
SourceControlPreferencesService,
).getPreferences();
// License info
const pulsePacket = {
plan_name_current: this.license.getPlanName(),
quota: this.license.getTriggerLimit(),
usage: await LicenseService.getActiveTriggerCount(),
source_control_set_up: Container.get(SourceControlPreferencesService).isSourceControlSetup(),
branchName: sourceControlPreferences.branchName,
read_only_instance: sourceControlPreferences.branchReadOnly,
};
allPromises.push(this.track('pulse', pulsePacket));
return Promise.all(allPromises);

View file

@ -222,16 +222,24 @@ export class WorkflowsService {
);
}
// Update the workflow's version
workflow.versionId = uuid();
LoggerProxy.verbose(
`Updating versionId for workflow ${workflowId} for user ${user.id} after saving`,
{
previousVersionId: shared.workflow.versionId,
newVersionId: workflow.versionId,
},
);
if (
Object.keys(workflow).length === 3 &&
workflow.id !== undefined &&
workflow.versionId !== undefined &&
workflow.active !== undefined
) {
// we're just updating the active status of the workflow, don't update the versionId
} else {
// Update the workflow's version
workflow.versionId = uuid();
LoggerProxy.verbose(
`Updating versionId for workflow ${workflowId} for user ${user.id} after saving`,
{
previousVersionId: shared.workflow.versionId,
newVersionId: workflow.versionId,
},
);
}
// check credentials for old format
await WorkflowHelpers.replaceInvalidCredentials(workflow);

View file

@ -0,0 +1,72 @@
import type { SuperAgentTest } from 'supertest';
import { SOURCE_CONTROL_API_ROOT } from '@/environments/sourceControl/constants';
import * as testDb from '../shared/testDb';
import * as utils from '../shared/utils/';
import type { User } from '@db/entities/User';
import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper';
import Container from 'typedi';
import { License } from '@/License';
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
import { SourceControlService } from '@/environments/sourceControl/sourceControl.service.ee';
import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile';
let authOwnerAgent: SuperAgentTest;
let authMemberAgent: SuperAgentTest;
let owner: User;
let member: User;
const sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true);
const testServer = utils.setupTestServer({
endpointGroups: ['sourceControl', 'license', 'auth'],
enabledFeatures: ['feat:sourceControl', 'feat:sharing'],
});
beforeAll(async () => {
const globalOwnerRole = await testDb.getGlobalOwnerRole();
const globalMemberRole = await testDb.getGlobalMemberRole();
owner = await testDb.createUser({ globalRole: globalOwnerRole });
member = await testDb.createUser({ globalRole: globalMemberRole });
authOwnerAgent = testServer.authAgentFor(owner);
authMemberAgent = testServer.authAgentFor(member);
Container.get(License).isSourceControlLicensed = () => true;
Container.get(SourceControlPreferencesService).isSourceControlConnected = () => true;
});
describe('GET /sourceControl/preferences', () => {
test('should return Source Control preferences', async () => {
await authOwnerAgent
.get(`/${SOURCE_CONTROL_API_ROOT}/preferences`)
.expect(200)
.expect((res) => {
return 'repositoryUrl' in res.body && 'branchName' in res.body;
});
});
test('should return repo sync status', async () => {
Container.get(SourceControlService).getStatus = async () => {
return [
{
id: 'haQetoXq9GxHSkft',
name: 'My workflow 6 edit',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: true,
file: '/Users/michael/.n8n/git/workflows/haQetoXq9GxHSkft.json',
updatedAt: '2023-07-14T11:24:41.000Z',
},
] as SourceControlledFile[];
};
await authOwnerAgent
.get(`/${SOURCE_CONTROL_API_ROOT}/get-status`)
.query({ direction: 'push', preferLocalVersion: 'true', verbose: 'false' })
.expect(200)
.expect((res) => {
const data: SourceControlledFile[] = res.body.data;
expect(data.length).toBe(1);
expect(data[0].id).toBe('haQetoXq9GxHSkft');
});
});
});

View file

@ -1,27 +0,0 @@
import type { SuperAgentTest } from 'supertest';
import { SOURCE_CONTROL_API_ROOT } from '@/environments/sourceControl/constants';
import * as testDb from '../shared/testDb';
import * as utils from '../shared/utils/';
let authOwnerAgent: SuperAgentTest;
const testServer = utils.setupTestServer({
endpointGroups: ['sourceControl'],
enabledFeatures: ['feat:sourceControl'],
});
beforeAll(async () => {
const owner = await testDb.createOwner();
authOwnerAgent = testServer.authAgentFor(owner);
});
describe('GET /sourceControl/preferences', () => {
test('should return Source Control preferences', async () => {
await authOwnerAgent
.get(`/${SOURCE_CONTROL_API_ROOT}/preferences`)
.expect(200)
.expect((res) => {
return 'repositoryUrl' in res.body && 'branchName' in res.body;
});
});
});

View file

@ -759,13 +759,12 @@ describe('PATCH /workflows/:id - validate interim updates', () => {
const { versionId: memberVersionId } = memberGetResponse.body.data;
await authMemberAgent
.patch(`/workflows/${id}`)
.send({ active: true, versionId: memberVersionId });
.send({ active: true, versionId: memberVersionId, name: 'Update by member' });
// owner blocked from activating workflow
const activationAttemptResponse = await authOwnerAgent
.patch(`/workflows/${id}`)
.send({ active: true, versionId: ownerVersionId });
.send({ active: true, versionId: ownerVersionId, name: 'Update by owner' });
expect(activationAttemptResponse.status).toBe(400);
expect(activationAttemptResponse.body.code).toBe(100);
@ -793,13 +792,13 @@ describe('PATCH /workflows/:id - validate interim updates', () => {
await authOwnerAgent
.patch(`/workflows/${id}`)
.send({ active: true, versionId: ownerSecondVersionId });
.send({ active: true, versionId: ownerSecondVersionId, name: 'Owner update again' });
// member blocked from activating workflow
const updateAttemptResponse = await authMemberAgent
.patch(`/workflows/${id}`)
.send({ active: true, versionId: memberVersionId });
.send({ active: true, versionId: memberVersionId, name: 'Update by member' });
expect(updateAttemptResponse.status).toBe(400);
expect(updateAttemptResponse.body.code).toBe(100);

View file

@ -0,0 +1,256 @@
import Container from 'typedi';
import {
generateSshKeyPair,
getRepoType,
getTrackingInformationFromPostPushResult,
getTrackingInformationFromPrePushResult,
getTrackingInformationFromPullResult,
sourceControlFoldersExistCheck,
} from '@/environments/sourceControl/sourceControlHelper.ee';
import { License } from '@/License';
import { SourceControlPreferencesService } from '@/environments/sourceControl/sourceControlPreferences.service.ee';
import { UserSettings } from 'n8n-core';
import path from 'path';
import {
SOURCE_CONTROL_SSH_FOLDER,
SOURCE_CONTROL_GIT_FOLDER,
SOURCE_CONTROL_SSH_KEY_NAME,
} from '@/environments/sourceControl/constants';
import { LoggerProxy } from 'n8n-workflow';
import { getLogger } from '@/Logger';
import { constants as fsConstants, accessSync } from 'fs';
import type { SourceControlledFile } from '@/environments/sourceControl/types/sourceControlledFile';
const pushResult: SourceControlledFile[] = [
{
file: 'credential_stubs/kkookWGIeey9K4Kt.json',
id: 'kkookWGIeey9K4Kt',
name: '(deleted)',
type: 'credential',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: '',
pushed: true,
},
{
file: 'variable_stubs.json',
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
pushed: true,
},
{
file: 'workflows/BpFS26gViuGqrIVP.json',
id: 'BpFS26gViuGqrIVP',
name: 'My workflow 5',
type: 'workflow',
status: 'modified',
location: 'remote',
conflict: true,
pushed: true,
updatedAt: '2023-07-10T10:10:59.000Z',
},
{
file: 'workflows/BpFS26gViuGqrIVP.json',
id: 'BpFS26gViuGqrIVP',
name: 'My workflow 5',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: true,
updatedAt: '2023-07-10T10:10:59.000Z',
},
{
file: 'workflows/dAU6dNthm4TR3gXx.json',
id: 'dAU6dNthm4TR3gXx',
name: 'My workflow 7',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
pushed: true,
updatedAt: '2023-07-10T10:02:45.186Z',
},
{
file: 'workflows/haQetoXq9GxHSkft.json',
id: 'haQetoXq9GxHSkft',
name: 'My workflow 6',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
updatedAt: '2023-07-10T10:02:39.276Z',
},
];
const pullResult: SourceControlledFile[] = [
{
file: 'credential_stubs/kkookWGIeey9K4Kt.json',
id: 'kkookWGIeey9K4Kt',
name: '(deleted)',
type: 'credential',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: '',
},
{
file: 'credential_stubs/abcdeWGIeey9K4aa.json',
id: 'abcdeWGIeey9K4aa',
name: 'modfied credential',
type: 'credential',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
},
{
file: 'workflows/BpFS26gViuGqrIVP.json',
id: 'BpFS26gViuGqrIVP',
name: '(deleted)',
type: 'workflow',
status: 'deleted',
location: 'local',
conflict: false,
updatedAt: '',
},
{
file: 'variable_stubs.json',
id: 'variables',
name: 'variables',
type: 'variables',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '',
},
{
file: 'workflows/dAU6dNthm4TR3gXx.json',
id: 'dAU6dNthm4TR3gXx',
name: 'My workflow 7',
type: 'workflow',
status: 'created',
location: 'local',
conflict: false,
updatedAt: '2023-07-10T10:02:45.186Z',
},
{
file: 'workflows/haQetoXq9GxHSkft.json',
id: 'haQetoXq9GxHSkft',
name: 'My workflow 6',
type: 'workflow',
status: 'modified',
location: 'local',
conflict: false,
updatedAt: '2023-07-10T10:02:39.276Z',
},
];
beforeAll(async () => {
LoggerProxy.init(getLogger());
Container.get(License).isSourceControlLicensed = () => true;
Container.get(SourceControlPreferencesService).getPreferences = () => ({
branchName: 'main',
connected: true,
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
publicKey:
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDBSz2nMZAiUBWe6n89aWd5x9QMcIOaznVW3fpuCYC4L n8n deploy key',
});
});
describe('Source Control', () => {
it('should generate an SSH key pair', async () => {
const keyPair = await generateSshKeyPair();
expect(keyPair.privateKey).toBeTruthy();
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
expect(keyPair.publicKey).toBeTruthy();
expect(keyPair.publicKey).toContain('ssh-ed25519');
});
it('should check for git and ssh folders and create them if required', async () => {
const userFolder = UserSettings.getUserN8nFolderPath();
const sshFolder = path.join(userFolder, SOURCE_CONTROL_SSH_FOLDER);
const gitFolder = path.join(userFolder, SOURCE_CONTROL_GIT_FOLDER);
const sshKeyName = path.join(sshFolder, SOURCE_CONTROL_SSH_KEY_NAME);
let hasThrown = false;
try {
accessSync(sshFolder, fsConstants.F_OK);
} catch (error) {
hasThrown = true;
}
expect(hasThrown).toBeTruthy();
hasThrown = false;
try {
accessSync(gitFolder, fsConstants.F_OK);
} catch (error) {
hasThrown = true;
}
expect(hasThrown).toBeTruthy();
// create missing folders
expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(false);
// find folders this time
expect(sourceControlFoldersExistCheck([gitFolder, sshFolder], true)).toBe(true);
expect(accessSync(sshFolder, fsConstants.F_OK)).toBeUndefined();
expect(accessSync(gitFolder, fsConstants.F_OK)).toBeUndefined();
});
it('should check if source control is licensed', async () => {
expect(Container.get(License).isSourceControlLicensed()).toBe(true);
});
it('should get repo type from url', async () => {
expect(getRepoType('git@github.com:n8ntest/n8n_testrepo.git')).toBe('github');
expect(getRepoType('git@gitlab.com:n8ntest/n8n_testrepo.git')).toBe('gitlab');
expect(getRepoType('git@mygitea.io:n8ntest/n8n_testrepo.git')).toBe('other');
});
it('should get tracking information from pre-push results', () => {
const trackingResult = getTrackingInformationFromPrePushResult(pushResult);
expect(trackingResult).toEqual({
workflows_eligible: 3,
workflows_eligible_with_conflicts: 1,
creds_eligible: 1,
creds_eligible_with_conflicts: 0,
variables_eligible: 1,
});
});
it('should get tracking information from post-push results', () => {
const trackingResult = getTrackingInformationFromPostPushResult(pushResult);
expect(trackingResult).toEqual({
workflows_pushed: 2,
workflows_eligible: 3,
creds_pushed: 1,
variables_pushed: 1,
});
});
it('should get tracking information from pull results', () => {
const trackingResult = getTrackingInformationFromPullResult(pullResult);
expect(trackingResult).toEqual({
cred_conflicts: 1,
workflow_conflicts: 1,
workflow_updates: 3,
});
});
it('should class validate correct preferences', async () => {
const validPreferences = {
branchName: 'main',
repositoryUrl: 'git@example.com:n8ntest/n8n_testrepo.git',
branchReadOnly: false,
branchColor: '#5296D6',
};
const validationResult = await Container.get(
SourceControlPreferencesService,
).validateSourceControlPreferences(validPreferences);
expect(validationResult).toBeTruthy();
});
});

View file

@ -1,11 +0,0 @@
import { generateSshKeyPair } from '../../src/environments/sourceControl/sourceControlHelper.ee';
describe('Source Control', () => {
it('should generate an SSH key pair', () => {
const keyPair = generateSshKeyPair();
expect(keyPair.privateKey).toBeTruthy();
expect(keyPair.privateKey).toContain('BEGIN OPENSSH PRIVATE KEY');
expect(keyPair.publicKey).toBeTruthy();
expect(keyPair.publicKey).toContain('ssh-ed25519');
});
});

View file

@ -1451,8 +1451,6 @@ export type SamlPreferencesExtractedData = {
export type SourceControlPreferences = {
connected: boolean;
repositoryUrl: string;
authorName: string;
authorEmail: string;
branchName: string;
branches: string[];
branchReadOnly: boolean;

View file

@ -9,8 +9,6 @@ export function routesForSourceControl(server: Server) {
const defaultSourceControlPreferences: SourceControlPreferences = {
branchName: '',
branches: [],
authorName: '',
authorEmail: '',
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#1d6acb',

View file

@ -52,8 +52,13 @@ export const getStatus = async (context: IRestApiContext): Promise<SourceControl
export const getAggregatedStatus = async (
context: IRestApiContext,
options: {
direction: 'push' | 'pull';
preferLocalVersion: boolean;
verbose: boolean;
} = { direction: 'push', preferLocalVersion: true, verbose: false },
): Promise<SourceControlAggregatedFile[]> => {
return makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`);
return makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/get-status`, options);
};
export const disconnect = async (

View file

@ -152,7 +152,7 @@ import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver';
import { useTitleChange, useToast, useMessage, useLoadingService } from '@/composables';
import { useTitleChange, useToast, useMessage } from '@/composables';
import type { MessageBoxInputData } from 'element-ui/types/message-box';
import {
useUIStore,
@ -169,6 +169,7 @@ import { getWorkflowPermissions } from '@/permissions';
import { createEventBus } from 'n8n-design-system';
import { useCloudPlanStore } from '@/stores';
import { nodeViewEventBus } from '@/event-bus';
import { genericHelpers } from '@/mixins/genericHelpers';
const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) {
@ -181,7 +182,7 @@ const hasChanged = (prev: string[], curr: string[]) => {
export default defineComponent({
name: 'WorkflowDetails',
mixins: [workflowHelpers],
mixins: [workflowHelpers, genericHelpers],
components: {
TagsContainer,
PushConnectionTracker,
@ -199,10 +200,7 @@ export default defineComponent({
},
},
setup() {
const loadingService = useLoadingService();
return {
loadingService,
...useTitleChange(),
...useToast(),
...useMessage(),
@ -247,6 +245,9 @@ export default defineComponent({
isDirty(): boolean {
return this.uiStore.stateIsDirty;
},
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;
},
currentWorkflowTagIds(): string[] {
return this.workflowsStore.workflowTags;
},
@ -318,7 +319,8 @@ export default defineComponent({
disabled:
!this.sourceControlStore.isEnterpriseSourceControlEnabled ||
!this.onWorkflowPage ||
this.onExecutionsTab,
this.onExecutionsTab ||
this.readOnlyEnv,
});
actions.push({
@ -531,25 +533,20 @@ export default defineComponent({
break;
}
case WORKFLOW_MENU_ACTIONS.PUSH: {
this.loadingService.startLoading();
this.startLoading();
try {
await this.onSaveButtonClick();
const status = await this.sourceControlStore.getAggregatedStatus();
const workflowStatus = status.filter(
(s) =>
(s.id === this.currentWorkflowId && s.type === 'workflow') || s.type !== 'workflow',
);
this.uiStore.openModalWithData({
name: SOURCE_CONTROL_PUSH_MODAL_KEY,
data: { eventBus: this.eventBus, status: workflowStatus },
data: { eventBus: this.eventBus, status },
});
} catch (error) {
this.showError(error, this.$locale.baseText('error'));
} finally {
this.loadingService.stopLoading();
this.loadingService.setLoadingText(this.$locale.baseText('genericHelpers.loading'));
this.stopLoading();
}
break;

View file

@ -1,10 +1,12 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { computed, nextTick, ref } from 'vue';
import { useRouter } from 'vue-router/composables';
import { createEventBus } from 'n8n-design-system/utils';
import { useI18n, useLoadingService, useMessage, useToast } from '@/composables';
import { useUIStore, useSourceControlStore } from '@/stores';
import { useUIStore, useSourceControlStore, useUsersStore } from '@/stores';
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import type { SourceControlAggregatedFile } from '../Interface';
import { sourceControlEventBus } from '@/event-bus/source-control';
const props = defineProps<{
isCollapsed: boolean;
@ -17,6 +19,7 @@ const responseStatuses = {
const router = useRouter();
const loadingService = useLoadingService();
const uiStore = useUIStore();
const usersStore = useUsersStore();
const sourceControlStore = useSourceControlStore();
const message = useMessage();
const toast = useToast();
@ -29,10 +32,16 @@ const currentBranch = computed(() => {
return sourceControlStore.preferences.branchName;
});
const featureEnabled = computed(() => window.localStorage.getItem('source-control'));
// TODO: use this for release
// const featureEnabled = computed(
// () => sourceControlStore.preferences.connected && sourceControlStore.preferences.branchName,
// );
const isInstanceOwner = computed(() => usersStore.isInstanceOwner);
const setupButtonTooltipPlacement = computed(() => (props.isCollapsed ? 'right' : 'top'));
async function pushWorkfolder() {
loadingService.startLoading();
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.checkingForChanges'));
try {
const status = await sourceControlStore.getAggregatedStatus();
@ -53,7 +62,45 @@ async function pullWorkfolder() {
loadingService.setLoadingText(i18n.baseText('settings.sourceControl.loading.pull'));
try {
await sourceControlStore.pullWorkfolder(false);
const status: SourceControlAggregatedFile[] =
((await sourceControlStore.pullWorkfolder(
false,
)) as unknown as SourceControlAggregatedFile[]) || [];
const statusWithoutLocallyCreatedWorkflows = status.filter((file) => {
return !(file.type === 'workflow' && file.status === 'created' && file.location === 'local');
});
if (statusWithoutLocallyCreatedWorkflows.length === 0) {
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.upToDate.title'),
message: i18n.baseText('settings.sourceControl.pull.upToDate.description'),
type: 'success',
});
} else {
toast.showMessage({
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
const incompleteFileTypes = ['variables', 'credential'];
const hasVariablesOrCredentials = (status || []).some((file) => {
return incompleteFileTypes.includes(file.type);
});
if (hasVariablesOrCredentials) {
nextTick(() => {
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
type: 'info',
duration: 0,
showClose: true,
offset: 0,
});
});
}
}
sourceControlEventBus.emit('pull');
} catch (error) {
const errorResponse = error.response;
@ -78,12 +125,11 @@ const goToSourceControlSetup = async () => {
<template>
<div
v-if="featureEnabled"
v-if="featureEnabled && isInstanceOwner"
:class="{
[$style.sync]: true,
[$style.collapsed]: isCollapsed,
[$style.isConnected]:
sourceControlStore.preferences.connected && sourceControlStore.preferences.branchName,
[$style.isConnected]: featureEnabled,
}"
:style="{ borderLeftColor: sourceControlStore.preferences.branchColor }"
data-test-id="main-sidebar-source-control"
@ -93,7 +139,7 @@ const goToSourceControlSetup = async () => {
:class="$style.connected"
data-test-id="main-sidebar-source-control-connected"
>
<span>
<span :class="$style.branchName">
<n8n-icon icon="code-branch" />
{{ currentBranch }}
</span>
@ -178,6 +224,11 @@ const goToSourceControlSetup = async () => {
}
}
.branchName {
white-space: normal;
line-break: anywhere;
}
.collapsed {
text-align: center;
padding-left: var(--spacing-s);

View file

@ -12,7 +12,7 @@
class="node-name"
:value="node && node.name"
:nodeType="nodeType"
:isReadOnly="isReadOnly"
:readOnly="isReadOnly"
@input="nameChanged"
></NodeTitle>
<div v-if="isExecutable">

View file

@ -7,7 +7,7 @@
:class="$style.pinnedDataCallout"
>
{{ $locale.baseText('runData.pindata.thisDataIsPinned') }}
<span class="ml-4xs" v-if="!isReadOnlyRoute">
<span class="ml-4xs" v-if="!isReadOnlyRoute && !readOnlyEnv">
<n8n-link
theme="secondary"
size="small"
@ -59,7 +59,7 @@
data-test-id="ndv-run-data-display-mode"
/>
<n8n-icon-button
v-if="canPinData && !isReadOnlyRoute"
v-if="canPinData && !isReadOnlyRoute && !readOnlyEnv"
v-show="!editMode.enabled"
:title="$locale.baseText('runData.editOutput')"
:circle="false"
@ -100,7 +100,10 @@
:active="hasPinData"
icon="thumbtack"
:disabled="
editMode.enabled || (inputData.length === 0 && !hasPinData) || isReadOnlyRoute
editMode.enabled ||
(inputData.length === 0 && !hasPinData) ||
isReadOnlyRoute ||
readOnlyEnv
"
@click="onTogglePinData({ source: 'pin-icon-click' })"
data-test-id="ndv-pin-data"
@ -889,6 +892,9 @@ export default defineComponent({
isPaneTypeOutput(): boolean {
return this.paneType === 'output';
},
readOnlyEnv(): boolean {
return this.sourceControlStore.preferences.branchReadOnly;
},
},
methods: {
onItemHover(itemIndex: number | null) {

View file

@ -207,7 +207,6 @@ export default defineComponent({
}
break;
case 'users': // Fakedoor feature added via hooks when user management is disabled on cloud
case 'environments':
case 'logging':
this.$router.push({ name: VIEWS.FAKE_DOOR, params: { featureId: key } }).catch(() => {});
break;

View file

@ -3,28 +3,45 @@ import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
import type { PropType } from 'vue';
import type { EventBus } from 'n8n-design-system/utils';
import type { SourceControlStatus } from '@/Interface';
import type { SourceControlAggregatedFile } from '@/Interface';
import { useI18n, useLoadingService, useToast } from '@/composables';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores';
import { useRoute } from 'vue-router/composables';
import { useRoute, useRouter } from 'vue-router/composables';
import { computed, nextTick, ref } from 'vue';
import { sourceControlEventBus } from '@/event-bus/source-control';
const props = defineProps({
data: {
type: Object as PropType<{ eventBus: EventBus; status: SourceControlStatus }>,
type: Object as PropType<{ eventBus: EventBus; status: SourceControlAggregatedFile[] }>,
default: () => ({}),
},
});
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const incompleteFileTypes = ['variables', 'credential'];
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const { i18n } = useI18n();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
const route = useRoute();
const files = ref<SourceControlAggregatedFile[]>(props.data.status || []);
const workflowFiles = computed(() => {
return files.value.filter((file) => file.type === 'workflow');
});
const modifiedWorkflowFiles = computed(() => {
return workflowFiles.value.filter((file) => file.status === 'modified');
});
const deletedWorkflowFiles = computed(() => {
return workflowFiles.value.filter((file) => file.status === 'deleted');
});
function close() {
uiStore.closeModal(SOURCE_CONTROL_PULL_MODAL_KEY);
}
@ -35,11 +52,29 @@ async function pullWorkfolder() {
try {
await sourceControlStore.pullWorkfolder(true);
const hasVariablesOrCredentials = files.value.some((file) => {
return incompleteFileTypes.includes(file.type);
});
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.success.description'),
title: i18n.baseText('settings.sourceControl.pull.success.title'),
type: 'success',
});
if (hasVariablesOrCredentials) {
nextTick(() => {
toast.showMessage({
message: i18n.baseText('settings.sourceControl.pull.oneLastStep.description'),
title: i18n.baseText('settings.sourceControl.pull.oneLastStep.title'),
type: 'info',
duration: 0,
showClose: true,
offset: 0,
});
});
}
sourceControlEventBus.emit('pull');
} catch (error) {
toast.showError(error, 'Error');
} finally {
@ -59,7 +94,21 @@ async function pullWorkfolder() {
<div :class="$style.container">
<n8n-text>
{{ i18n.baseText('settings.sourceControl.modals.pull.description') }}
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ i18n.baseText('settings.sourceControl.modals.pull.description.learnMore') }}
</n8n-link>
</n8n-text>
<div v-if="modifiedWorkflowFiles.length > 0" class="mt-l">
<ul :class="$style.filesList">
<li v-for="file in modifiedWorkflowFiles" :key="file.id">
<n8n-link :class="$style.fileLink" new-window :to="`/workflow/${file.id}`">
{{ file.name }}
<n8n-icon icon="external-link-alt" />
</n8n-link>
</li>
</ul>
</div>
</div>
</template>
@ -81,6 +130,27 @@ async function pullWorkfolder() {
overflow-wrap: break-word;
}
.filesList {
list-style: inside;
margin-top: var(--spacing-3xs);
padding-left: var(--spacing-2xs);
li {
margin-top: var(--spacing-3xs);
}
}
.fileLink {
svg {
display: none;
margin-left: var(--spacing-4xs);
}
&:hover svg {
display: inline-flex;
}
}
.footer {
display: flex;
flex-direction: row;

View file

@ -23,12 +23,20 @@ const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
const loadingService = useLoadingService();
const uiStore = useUIStore();
const toast = useToast();
const { i18n } = useI18n();
const { i18n: locale } = useI18n();
const sourceControlStore = useSourceControlStore();
const route = useRoute();
const staged = ref<Record<string, boolean>>({});
const files = ref<SourceControlAggregatedFile[]>(props.data.status || []);
const files = ref<SourceControlAggregatedFile[]>(
props.data.status.filter((file, index, self) => {
// do not show remote workflows that are not yet created locally during push
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') {
return false;
}
return self.findIndex((f) => f.id === file.id) === index;
}) || [],
);
const commitMessage = ref('');
const loading = ref(true);
@ -55,10 +63,10 @@ const workflowId = computed(() => {
const sortedFiles = computed(() => {
const statusPriority = {
deleted: 1,
modified: 2,
renamed: 3,
created: 4,
modified: 1,
renamed: 2,
created: 3,
deleted: 4,
};
return [...files.value].sort((a, b) => {
@ -104,7 +112,7 @@ onMounted(async () => {
try {
staged.value = getStagedFilesByContext(files.value);
} catch (error) {
toast.showError(error, i18n.baseText('error'));
toast.showError(error, locale.baseText('error'));
} finally {
loading.value = false;
}
@ -152,10 +160,10 @@ function getStagedFilesByContext(files: SourceControlAggregatedFile[]): Record<s
stagedFiles[file.file] = true;
}
if (context.value === 'workflow' && file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
} else if (context.value === 'workflows' && file.type === 'workflow') {
stagedFiles[file.file] = true;
if (context.value === 'workflow') {
if (file.type === 'workflow' && file.id === workflowId.value) {
stagedFiles[file.file] = true;
}
}
});
@ -176,7 +184,7 @@ function close() {
function renderUpdatedAt(file: SourceControlAggregatedFile) {
const currentYear = new Date().getFullYear();
return i18n.baseText('settings.sourceControl.lastUpdated', {
return locale.baseText('settings.sourceControl.lastUpdated', {
interpolate: {
date: dateformat(
file.updatedAt,
@ -187,25 +195,32 @@ function renderUpdatedAt(file: SourceControlAggregatedFile) {
});
}
async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file);
async function onCommitKeyDownEnter() {
if (!isSubmitDisabled.value) {
await commitAndPush();
}
}
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
async function commitAndPush() {
const fileNames = files.value.filter((file) => staged.value[file.file]);
loadingService.startLoading(locale.baseText('settings.sourceControl.loading.push'));
close();
try {
await sourceControlStore.pushWorkfolder({
force: true,
commitMessage: commitMessage.value,
fileNames,
});
toast.showToast({
title: i18n.baseText('settings.sourceControl.modals.push.success.title'),
message: i18n.baseText('settings.sourceControl.modals.push.success.description'),
title: locale.baseText('settings.sourceControl.modals.push.success.title'),
message: locale.baseText('settings.sourceControl.modals.push.success.description'),
type: 'success',
});
} catch (error) {
toast.showError(error, i18n.baseText('error'));
toast.showError(error, locale.baseText('error'));
} finally {
loadingService.stopLoading();
}
@ -215,98 +230,106 @@ async function commitAndPush() {
<template>
<Modal
width="812px"
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
:title="locale.baseText('settings.sourceControl.modals.push.title')"
:eventBus="data.eventBus"
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
>
<template #content>
<div :class="$style.container">
<n8n-text>
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
<span v-if="context">
{{ i18n.baseText(`settings.sourceControl.modals.push.description.${context}`) }}
</span>
<n8n-link
:href="i18n.baseText('settings.sourceControl.modals.push.description.learnMore.url')"
>
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-link>
</n8n-text>
<div v-if="files.length > 0">
<div v-if="workflowFiles.length > 0">
<n8n-text>
{{ locale.baseText('settings.sourceControl.modals.push.description') }}
<n8n-link :to="locale.baseText('settings.sourceControl.docs.using.pushPull.url')">
{{ locale.baseText('settings.sourceControl.modals.push.description.learnMore') }}
</n8n-link>
</n8n-text>
<div v-if="workflowFiles.length > 0">
<div class="mt-l mb-2xs">
<n8n-checkbox
:indeterminate="selectAllIndeterminate"
:value="selectAll"
@input="onToggleSelectAll"
>
<n8n-text bold tag="strong">
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</n8n-text>
<n8n-text tag="strong" v-show="workflowFiles.length > 0">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
</n8n-checkbox>
</div>
<n8n-card
v-for="file in sortedFiles"
v-show="!defaultStagedFileTypes.includes(file.type)"
:key="file.file"
:class="$style.listItem"
@click="setStagedStatus(file, !staged[file.file])"
>
<div :class="$style.listItemBody">
<div class="mt-l mb-2xs">
<n8n-checkbox
:value="staged[file.file]"
:class="$style.listItemCheckbox"
@input="setStagedStatus(file, !staged[file.file])"
/>
<div>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.id }}</strong>
:indeterminate="selectAllIndeterminate"
:value="selectAll"
@input="onToggleSelectAll"
>
<n8n-text bold tag="strong">
{{ locale.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
</n8n-text>
<n8n-text bold v-else>
{{ file.name }}
<n8n-text tag="strong" v-show="workflowFiles.length > 0">
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
</n8n-text>
<div v-if="file.updatedAt">
<n8n-text color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</n8n-text>
</div>
<div v-if="file.conflict">
<n8n-text color="danger" size="small">
{{ i18n.baseText('settings.sourceControl.modals.push.overrideVersionInGit') }}
</n8n-text>
</div>
</div>
<div :class="$style.listItemStatus">
<n8n-badge class="mr-2xs" v-if="workflowId === file.id && file.type === 'workflow'">
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ file.status }}
</n8n-badge>
</div>
</n8n-checkbox>
</div>
</n8n-card>
<n8n-card
v-for="file in sortedFiles"
v-show="!defaultStagedFileTypes.includes(file.type)"
:key="file.file"
:class="$style.listItem"
@click="setStagedStatus(file, !staged[file.file])"
>
<div :class="$style.listItemBody">
<n8n-checkbox
:value="staged[file.file]"
:class="$style.listItemCheckbox"
@input="setStagedStatus(file, !staged[file.file])"
/>
<div>
<n8n-text v-if="file.status === 'deleted'" color="text-light">
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
<strong>{{ file.name || file.id }}</strong>
</n8n-text>
<n8n-text bold v-else> {{ file.name }} </n8n-text>
<div v-if="file.updatedAt">
<n8n-text color="text-light" size="small">
{{ renderUpdatedAt(file) }}
</n8n-text>
</div>
</div>
<div :class="$style.listItemStatus">
<n8n-badge
class="mr-2xs"
v-if="workflowId === file.id && file.type === 'workflow'"
>
Current workflow
</n8n-badge>
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
{{ locale.baseText(`settings.sourceControl.status.${file.status}`) }}
</n8n-badge>
</div>
</div>
</n8n-card>
</div>
<n8n-notice class="mt-0" v-else>
<i18n path="settings.sourceControl.modals.push.noWorkflowChanges">
<template #link>
<n8n-link
size="small"
:to="locale.baseText('settings.sourceControl.docs.using.url')"
>
{{
locale.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo')
}}
</n8n-link>
</template>
</i18n>
</n8n-notice>
<n8n-text bold tag="p" class="mt-l mb-2xs">
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
{{ locale.baseText('settings.sourceControl.modals.push.commitMessage') }}
</n8n-text>
<n8n-input
type="text"
v-model="commitMessage"
:placeholder="
i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
locale.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
"
@keydown.enter.native="onCommitKeyDownEnter"
/>
</div>
<div v-else-if="!loading">
<n8n-callout class="mt-l">
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
</n8n-callout>
<n8n-notice class="mt-0 mb-0">
{{ locale.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
</n8n-notice>
</div>
</div>
</template>
@ -314,10 +337,10 @@ async function commitAndPush() {
<template #footer>
<div :class="$style.footer">
<n8n-button type="tertiary" class="mr-2xs" @click="close">
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
{{ locale.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
</n8n-button>
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush">
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
{{ locale.baseText('settings.sourceControl.modals.push.buttons.save') }}
</n8n-button>
</div>
</template>

View file

@ -9,10 +9,12 @@ import { i18nInstance } from '@/plugins/i18n';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue';
import { useSourceControlStore, useUIStore } from '@/stores';
import { useUsersStore } from '@/stores/users.store';
let pinia: ReturnType<typeof createTestingPinia>;
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let uiStore: ReturnType<typeof useUIStore>;
let usersStore: ReturnType<typeof useUsersStore>;
const renderComponent = (renderOptions: Parameters<typeof render>[1] = {}) => {
return render(
@ -41,6 +43,10 @@ describe('MainSidebarSourceControl', () => {
},
});
usersStore = useUsersStore(pinia);
vi.spyOn(usersStore, 'isInstanceOwner', 'get').mockReturnValue(true);
sourceControlStore = useSourceControlStore();
uiStore = useUIStore();
});
@ -62,8 +68,6 @@ describe('MainSidebarSourceControl', () => {
vi.spyOn(sourceControlStore, 'preferences', 'get').mockReturnValue({
branchName: 'main',
branches: [],
authorName: '',
authorEmail: '',
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#5296D6',

View file

@ -8,7 +8,7 @@
</div>
<div class="mt-xs mb-l">
<slot name="add-button">
<slot name="add-button" :disabled="disabled">
<n8n-button
size="large"
block

View file

@ -0,0 +1,3 @@
import { createEventBus } from 'n8n-design-system';
export const sourceControlEventBus = createEventBus();

View file

@ -91,7 +91,10 @@ export const workflowActivate = defineComponent({
return;
}
await this.updateWorkflow({ workflowId: currWorkflowId, active: newActiveState });
await this.updateWorkflow(
{ workflowId: currWorkflowId, active: newActiveState },
!this.uiStore.stateIsDirty,
);
} catch (error) {
const newStateName = newActiveState === true ? 'activated' : 'deactivated';
this.showError(

View file

@ -664,12 +664,17 @@ export const workflowHelpers = defineComponent({
return returnData['__xxxxxxx__'];
},
async updateWorkflow({ workflowId, active }: { workflowId: string; active?: boolean }) {
async updateWorkflow(
{ workflowId, active }: { workflowId: string; active?: boolean },
partialData = false,
) {
let data: IWorkflowDataUpdate = {};
const isCurrentWorkflow = workflowId === this.workflowsStore.workflowId;
if (isCurrentWorkflow) {
data = await this.getWorkflowDataToSave();
data = partialData
? { versionId: this.workflowsStore.workflowVersionId }
: await this.getWorkflowDataToSave();
} else {
const { versionId } = await this.workflowsStore.fetchWorkflow(workflowId);
data.versionId = versionId;
@ -699,6 +704,10 @@ export const workflowHelpers = defineComponent({
redirect = true,
forceSave = false,
): Promise<boolean> {
if (this.readOnlyEnv) {
return;
}
const currentWorkflow = id || this.$route.params.name;
const isLoading = this.loadingService !== null;

View file

@ -553,10 +553,6 @@
"expressionModalInput.undefined": "[undefined]",
"expressionModalInput.null": "null",
"fakeDoor.settings.environments.name": "Environments",
"fakeDoor.settings.environments.infoText": "Environments allow you to use different settings and credentials in a workflow when you're building it vs when it's running in production",
"fakeDoor.settings.environments.actionBox.title": "Were working on environments (as a paid feature)",
"fakeDoor.settings.environments.actionBox.title.cloud": "Were working on this",
"fakeDoor.settings.environments.actionBox.description": "If you'd like to be the first to hear when it's ready, join the list.",
"fakeDoor.settings.sso.name": "Single Sign-On",
"fakeDoor.settings.sso.actionBox.title": "Were working on this (as a paid feature)",
"fakeDoor.settings.sso.actionBox.title.cloud": "Were working on this",
@ -588,8 +584,12 @@
"genericHelpers.minShort": "m",
"genericHelpers.sec": "sec",
"genericHelpers.secShort": "s",
"genericHelpers.showMessage.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
"genericHelpers.showMessage.title": "Cannot edit execution",
"readOnly.showMessage.executions.message": "Executions are read-only. Make changes from the <b>Workflow</b> tab.",
"readOnly.showMessage.executions.title": "Cannot edit execution",
"readOnlyEnv.showMessage.executions.message": "Executions are read-only.",
"readOnlyEnv.showMessage.executions.title": "Cannot edit execution",
"readOnlyEnv.showMessage.workflows.message": "Workflows are read-only in protected instances.",
"readOnlyEnv.showMessage.workflows.title": "Cannot edit workflow",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
@ -1243,7 +1243,7 @@
"settings.log-streaming": "Log Streaming",
"settings.log-streaming.heading": "Log Streaming",
"settings.log-streaming.add": "Add new destination",
"settings.log-streaming.actionBox.title": "Available on Enterprise plan",
"settings.log-streaming.actionBox.title": "Available on the Enterprise plan",
"settings.log-streaming.actionBox.description": "Log Streaming is available as a paid feature. Learn more about it.",
"settings.log-streaming.actionBox.button": "See plans",
"settings.log-streaming.infoText": "Send logs to external endpoints of your choice. You can also write logs to a file or the console using environment variables. <a href=\"https://docs.n8n.io/log-streaming/\" target=\"_blank\">More info</a>",
@ -1321,8 +1321,8 @@
"settings.usageAndPlan.license.activation.success.message": "Your {name} {type} has been successfully activated.",
"settings.usageAndPlan.desktop.title": "Upgrade to n8n Cloud for the full experience",
"settings.usageAndPlan.desktop.description": "Cloud plans allow you to collaborate with teammates. Plus you dont need to leave this app open all the time for your workflows to run.",
"settings.sourceControl.title": "Source Control",
"settings.sourceControl.actionBox.title": "Available on Enterprise plan",
"settings.sourceControl.title": "Environments",
"settings.sourceControl.actionBox.title": "Available on the Enterprise plan",
"settings.sourceControl.actionBox.description": "Use multiple instances for different environments (dev, prod, etc.), deploying between them via a Git repository.",
"settings.sourceControl.actionBox.description.link": "More info",
"settings.sourceControl.actionBox.buttonText": "See plans",
@ -1336,7 +1336,7 @@
"settings.sourceControl.authorEmail": "Commit author email",
"settings.sourceControl.authorEmailInvalid": "The provided email is not correct",
"settings.sourceControl.sshKey": "SSH Key",
"settings.sourceControl.sshKeyDescription": "Paste the SSH key in your git repository settings. {link}.",
"settings.sourceControl.sshKeyDescription": "Paste the SSH key in your git repository/account settings. {link}",
"settings.sourceControl.sshKeyDescriptionLink": "More info",
"settings.sourceControl.refreshSshKey": "Refresh Key",
"settings.sourceControl.refreshSshKey.successful.title": "SSH Key refreshed successfully",
@ -1347,9 +1347,8 @@
"settings.sourceControl.button.save": "Save settings",
"settings.sourceControl.instanceSettings": "Instance settings",
"settings.sourceControl.branches": "Branch connected to this n8n instance",
"settings.sourceControl.readonly": "{bold}: prevent editing workflows (recommended for production environments). {link}",
"settings.sourceControl.readonly.bold": "Read-only instance",
"settings.sourceControl.readonly.link": "Learn more.",
"settings.sourceControl.protected": "{bold}: prevent editing workflows (recommended for production environments).",
"settings.sourceControl.protected.bold": "Protected instance",
"settings.sourceControl.color": "Color",
"settings.sourceControl.switchBranch.title": "Switch to {branch} branch",
"settings.sourceControl.switchBranch.description": "Please confirm you want to switch the current n8n instance to the branch: {branch}",
@ -1360,26 +1359,30 @@
"settings.sourceControl.button.push": "Push",
"settings.sourceControl.button.pull": "Pull",
"settings.sourceControl.modals.push.title": "Commit and push changes",
"settings.sourceControl.modals.push.description": "Select the files you want to stage in your commit and add a commit message. ",
"settings.sourceControl.modals.push.description.workflows": "Since you are on the Workflows page, the modified workflow files have been pre-selected for you.",
"settings.sourceControl.modals.push.description.workflow": "Since you are currently editing a Workflow, the modified workflow file has been pre-selected for you.",
"settings.sourceControl.modals.push.description.credentials": "Since you are on the Credentials page, the modified credential files have been pre-selected for you.",
"settings.sourceControl.modals.push.description.learnMore": "Learn more",
"settings.sourceControl.modals.push.description.learnMore.url": "https://docs.n8n.io/source-control/using/",
"settings.sourceControl.modals.push.description": "Workflows you push will overwrite any existing versions in the repository. ",
"settings.sourceControl.modals.push.description.learnMore": "More info",
"settings.sourceControl.modals.push.filesToCommit": "Files to commit",
"settings.sourceControl.modals.push.workflowsToCommit": "Workflows to commit",
"settings.sourceControl.modals.push.workflowsToCommit": "Select workflows",
"settings.sourceControl.modals.push.everythingIsUpToDate": "Everything is up to date",
"settings.sourceControl.modals.push.overrideVersionInGit": "This will override the version in Git",
"settings.sourceControl.modals.push.noWorkflowChanges": "No workflow changes to push. Only modified credentials, variables, and tags will be pushed. {link}",
"settings.sourceControl.modals.push.noWorkflowChanges.moreInfo": "More info",
"settings.sourceControl.modals.push.commitMessage": "Commit message",
"settings.sourceControl.modals.push.commitMessage.placeholder": "e.g. My commit",
"settings.sourceControl.modals.push.buttons.cancel": "Cancel",
"settings.sourceControl.modals.push.buttons.save": "Commit and Push",
"settings.sourceControl.modals.push.success.title": "Pushed successfully",
"settings.sourceControl.modals.push.success.description": "The files you selected were committed and pushed to the remote repository",
"settings.sourceControl.status.modified": "Modified",
"settings.sourceControl.status.deleted": "Deleted",
"settings.sourceControl.status.created": "New",
"settings.sourceControl.pull.oneLastStep.title": "One last step",
"settings.sourceControl.pull.oneLastStep.description": "You have new creds/vars. Fill them out to make sure your workflows function properly",
"settings.sourceControl.pull.success.title": "Pulled successfully",
"settings.sourceControl.pull.success.description": "Make sure you fill out the details of any new credentials or variables",
"settings.sourceControl.modals.pull.title": "Override local changes?",
"settings.sourceControl.modals.pull.description": "Some remote changes are going to override some of your local changes. Are you sure you want to continue?",
"settings.sourceControl.pull.upToDate.title": "Up to date",
"settings.sourceControl.pull.upToDate.description": "No workflow changes to pull from Git",
"settings.sourceControl.modals.pull.title": "Pull changes",
"settings.sourceControl.modals.pull.description": "These workflows will be updated, and any local changes to them will be overridden. To keep the local version, push it before pulling.",
"settings.sourceControl.modals.pull.description.learnMore": "More info",
"settings.sourceControl.modals.pull.buttons.cancel": "@:_reusableBaseText.cancel",
"settings.sourceControl.modals.pull.buttons.save": "Pull and override",
"settings.sourceControl.modals.disconnect.title": "Disconnect Git repository",
@ -1390,6 +1393,7 @@
"settings.sourceControl.modals.refreshSshKey.message": "This will delete the current SSH key and create a new one. You will not be able to authenticate with the current key anymore.",
"settings.sourceControl.modals.refreshSshKey.cancel": "Cancel",
"settings.sourceControl.modals.refreshSshKey.confirm": "Refresh key",
"settings.sourceControl.loading.connecting": "Connecting",
"settings.sourceControl.toast.connected.title": "Git repository connected",
"settings.sourceControl.toast.connected.message": "Select the branch to complete the configuration",
"settings.sourceControl.toast.connected.error": "Error connecting to Git",
@ -1397,18 +1401,21 @@
"settings.sourceControl.toast.disconnected.message": "You can no longer sync your instance with the remote repository",
"settings.sourceControl.toast.disconnected.error": "Error disconnecting from Git",
"settings.sourceControl.loading.pull": "Pulling from remote",
"settings.sourceControl.loading.checkingForChanges": "Checking for changes",
"settings.sourceControl.loading.push": "Pushing to remote",
"settings.sourceControl.lastUpdated": "Last updated {date} at {time}",
"settings.sourceControl.saved.title": "Settings successfully saved",
"settings.sourceControl.refreshBranches.tooltip": "Reload branches list",
"settings.sourceControl.refreshBranches.success": "Branches successfully refreshed",
"settings.sourceControl.refreshBranches.error": "Error refreshing branches",
"settings.sourceControl.docs.url": "https://docs.n8n.io/source-control/",
"settings.sourceControl.docs.setup.url": "https://docs.n8n.io/source-control/setup/",
"settings.sourceControl.docs.using.url": "https://docs.n8n.io/source-control/using/",
"settings.sourceControl.docs.url": "https://docs.n8n.io/source-control-environments/",
"settings.sourceControl.docs.setup.url": "https://docs.n8n.io/source-control-environments/setup/",
"settings.sourceControl.docs.setup.ssh.url": "https://docs.n8n.io/source-control-environments/setup/#step-3-set-up-a-deploy-key",
"settings.sourceControl.docs.using.url": "https://docs.n8n.io/source-control-environments/using/",
"settings.sourceControl.docs.using.pushPull.url": "https://docs.n8n.io/source-control-environments/using/push-pull",
"showMessage.cancel": "@:_reusableBaseText.cancel",
"settings.auditLogs.title": "Audit Logs",
"settings.auditLogs.actionBox.title": "Available on Enterprise plan",
"settings.auditLogs.actionBox.title": "Available on the Enterprise plan",
"settings.auditLogs.actionBox.description": "Upgrade to see the audit logs of your n8n instance.",
"settings.auditLogs.actionBox.buttonText": "See plans",
"showMessage.ok": "OK",
@ -1780,7 +1787,7 @@
"contextual.workflows.sharing.unavailable.button.cloud": "Upgrade now",
"contextual.workflows.sharing.unavailable.button.desktop": "View plans",
"contextual.variables.unavailable.title": "Available on Enterprise plan",
"contextual.variables.unavailable.title": "Available on the Enterprise plan",
"contextual.variables.unavailable.title.cloud": "Available on Pro plan",
"contextual.variables.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate",
"contextual.variables.unavailable.description": "Variables can be used to store and access data across workflows. Reference them in n8n using the prefix <code>$vars</code> (e.g. <code>$vars.myVariable</code>). Variables are immutable and cannot be modified within your workflows.<br/><a href=\"https://docs.n8n.io/environments/variables/\" target=\"_blank\">Learn more in the docs.</a>",
@ -1831,7 +1838,7 @@
"settings.ldap.confirmMessage.beforeSaveForm.confirmButtonText": "Yes, disable it",
"settings.ldap.confirmMessage.beforeSaveForm.headline": "Are you sure you want to disable LDAP login?",
"settings.ldap.confirmMessage.beforeSaveForm.message": "If you do so, all LDAP users will be converted to email users.",
"settings.ldap.disabled.title": "Available on Enterprise plan",
"settings.ldap.disabled.title": "Available on the Enterprise plan",
"settings.ldap.disabled.description": "LDAP is available as a paid feature. Learn more about it.",
"settings.ldap.disabled.buttonText": "See plans",
"settings.ldap.toast.sync.success": "Synchronization succeeded",
@ -1915,7 +1922,7 @@
"settings.sso.settings.save.activate.test": "Test settings",
"settings.sso.settings.save.error": "Error saving SAML SSO configuration",
"settings.sso.settings.footer.hint": "Don't forget to activate SAML SSO once you've saved the settings.",
"settings.sso.actionBox.title": "Available on Enterprise plan",
"settings.sso.actionBox.title": "Available on the Enterprise plan",
"settings.sso.actionBox.description": "Use Single Sign On to consolidate authentication into a single platform to improve security and agility.",
"settings.sso.actionBox.buttonText": "See plans",
"sso.login.divider": "or",

View file

@ -560,7 +560,7 @@ export const routes = [
},
},
{
path: 'source-control',
path: 'environments',
name: VIEWS.SOURCE_CONTROL,
components: {
settingsView: SettingsSourceControl,
@ -570,7 +570,7 @@ export const routes = [
pageCategory: 'settings',
getProperties(route: Route) {
return {
feature: 'vc',
feature: 'environments',
};
},
},
@ -578,9 +578,6 @@ export const routes = [
allow: {
role: [ROLE.Owner],
},
deny: {
shouldDeny: () => !window.localStorage.getItem('source-control'),
},
},
},
},

View file

@ -24,8 +24,6 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
const preferences = reactive<SourceControlPreferences>({
branchName: '',
branches: [],
authorName: defaultAuthor.value.name,
authorEmail: defaultAuthor.value.email,
repositoryUrl: '',
branchReadOnly: false,
branchColor: '#5296D6',
@ -39,16 +37,30 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
commitMessage: 'commit message',
});
const pushWorkfolder = async (data: { commitMessage: string; fileNames?: string[] }) => {
const pushWorkfolder = async (data: {
commitMessage: string;
fileNames?: Array<{
conflict: boolean;
file: string;
id: string;
location: string;
name: string;
status: string;
type: string;
updatedAt?: string | undefined;
}>;
force: boolean;
}) => {
state.commitMessage = data.commitMessage;
await vcApi.pushWorkfolder(rootStore.getRestApiContext, {
force: data.force,
message: data.commitMessage,
...(data.fileNames ? { fileNames: data.fileNames } : {}),
});
};
const pullWorkfolder = async (force: boolean) => {
await vcApi.pullWorkfolder(rootStore.getRestApiContext, { force });
return vcApi.pullWorkfolder(rootStore.getRestApiContext, { force });
};
const setPreferences = (data: Partial<SourceControlPreferences>) => {

View file

@ -152,16 +152,6 @@ export const useUIStore = defineStore(STORES.UI, {
currentView: '',
mainPanelPosition: 0.5,
fakeDoorFeatures: [
{
id: FAKE_DOOR_FEATURES.ENVIRONMENTS,
featureName: 'fakeDoor.settings.environments.name',
icon: 'server',
infoText: 'fakeDoor.settings.environments.infoText',
actionBoxTitle: 'fakeDoor.settings.environments.actionBox.title',
actionBoxDescription: 'fakeDoor.settings.environments.actionBox.description',
linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=environments',
uiLocations: ['settings'],
},
{
id: FAKE_DOOR_FEATURES.SSO,
featureName: 'fakeDoor.settings.sso.name',

View file

@ -315,6 +315,8 @@ import {
N8nPlusEndpointType,
EVENT_PLUS_ENDPOINT_CLICK,
} from '@/plugins/endpoints/N8nPlusEndpointType';
import type { ElNotificationComponent } from 'element-ui/types/notification';
import { sourceControlEventBus } from '@/event-bus/source-control';
interface AddNodeOptions {
position?: XYPosition;
@ -362,6 +364,8 @@ export default defineComponent({
watch: {
// Listen to route changes and load the workflow accordingly
$route(to: Route, from: Route) {
this.readOnlyEnvRouteCheck();
const currentTab = getNodeViewTab(to);
const nodeViewNotInitialized = !this.uiStore.nodeViewInitialized;
let workflowChanged =
@ -422,7 +426,7 @@ export default defineComponent({
next();
return;
}
if (this.uiStore.stateIsDirty) {
if (this.uiStore.stateIsDirty && !this.readOnlyEnv) {
const confirmModal = await this.confirm(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
{
@ -620,6 +624,7 @@ export default defineComponent({
isProductionExecutionPreview: false,
enterTimer: undefined as undefined | ReturnType<typeof setTimeout>,
exitTimer: undefined as undefined | ReturnType<typeof setTimeout>,
readOnlyNotification: null as null | ElNotificationComponent,
// jsplumb automatically deletes all loose connections which is in turn recorded
// in undo history as a user action.
// This should prevent automatically removed connections from populating undo stack
@ -637,13 +642,24 @@ export default defineComponent({
},
methods: {
editAllowedCheck(): boolean {
if (this.readOnlyNotification?.visible) {
return;
}
if (this.isReadOnlyRoute || this.readOnlyEnv) {
this.showMessage({
// title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'),
message: this.$locale.baseText('genericHelpers.showMessage.message'),
this.readOnlyNotification = this.showMessage({
title: this.$locale.baseText(
this.readOnlyEnv
? `readOnlyEnv.showMessage.${this.isReadOnlyRoute ? 'executions' : 'workflows'}.title`
: 'readOnly.showMessage.executions.title',
),
message: this.$locale.baseText(
this.readOnlyEnv
? `readOnlyEnv.showMessage.${
this.isReadOnlyRoute ? 'executions' : 'workflows'
}.message`
: 'readOnly.showMessage.executions.message',
),
type: 'info',
duration: 0,
dangerouslyUseHTMLString: true,
});
@ -1465,6 +1481,10 @@ export default defineComponent({
* This method gets called when data got pasted into the window
*/
async receivedCopyPasteData(plainTextData: string): Promise<void> {
if (this.readOnlyEnv) {
return;
}
const currentTab = getNodeViewTab(this.$route);
if (currentTab === MAIN_HEADER_TABS.WORKFLOW) {
let workflowData: IWorkflowDataUpdate | undefined;
@ -2550,8 +2570,7 @@ export default defineComponent({
const templateId = this.$route.params.id;
await this.openWorkflowTemplate(templateId);
} else {
const result = this.uiStore.stateIsDirty;
if (result) {
if (this.uiStore.stateIsDirty && !this.readOnlyEnv) {
const confirmModal = await this.confirm(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
{
@ -3821,6 +3840,41 @@ export default defineComponent({
this.stopLoading();
}
},
readOnlyEnvRouteCheck() {
if (
this.readOnlyEnv &&
[VIEWS.NEW_WORKFLOW, VIEWS.TEMPLATE_IMPORT].includes(this.$route.name)
) {
this.$nextTick(async () => {
this.resetWorkspace();
this.uiStore.stateIsDirty = false;
await this.$router.replace({ name: VIEWS.WORKFLOWS });
});
}
},
},
async onSourceControlPull() {
let workflowId = null as string | null;
if (this.$route.params.name) {
workflowId = this.$route.params.name;
}
try {
await Promise.all([this.loadCredentials(), this.loadVariables(), this.tagsStore.fetchAll()]);
if (workflowId !== null && !this.uiStore.stateIsDirty) {
const workflow: IWorkflowDb | undefined = await this.workflowsStore.fetchWorkflow(
workflowId,
);
if (workflow) {
this.titleSet(workflow.name, 'IDLE');
await this.openWorkflow(workflow);
}
}
} catch (error) {
console.error(error);
}
},
async mounted() {
this.resetWorkspace();
@ -3850,6 +3904,7 @@ export default defineComponent({
);
return;
}
ready(async () => {
try {
try {
@ -3913,6 +3968,10 @@ export default defineComponent({
}, promptTimeout);
}
}
sourceControlEventBus.on('pull', this.onSourceControlPull);
this.readOnlyEnvRouteCheck();
},
activated() {
const openSideMenu = this.uiStore.addFirstStepOnLoad;
@ -3976,6 +4035,7 @@ export default defineComponent({
nodeViewEventBus.off('importWorkflowData', this.onImportWorkflowDataEvent);
nodeViewEventBus.off('importWorkflowUrl', this.onImportWorkflowUrlEvent);
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
sourceControlEventBus.off('pull', this.onSourceControlPull);
},
});
</script>

View file

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, reactive, ref, onMounted } from 'vue';
import type { Rule, RuleGroup } from 'n8n-design-system/types';
import { MODAL_CONFIRM, VALID_EMAIL_REGEX } from '@/constants';
import { MODAL_CONFIRM } from '@/constants';
import { useUIStore, useSourceControlStore } from '@/stores';
import { useToast, useMessage, useLoadingService, useI18n } from '@/composables';
import CopyInput from '@/components/CopyInput.vue';
@ -13,9 +13,6 @@ const toast = useToast();
const message = useMessage();
const loadingService = useLoadingService();
const sourceControlDocsSetupUrl = computed(() =>
locale.baseText('settings.sourceControl.docs.setup.url'),
);
const isConnected = ref(false);
const branchNameOptions = computed(() =>
sourceControlStore.preferences.branches.map((branch) => ({
@ -26,10 +23,9 @@ const branchNameOptions = computed(() =>
const onConnect = async () => {
loadingService.startLoading();
loadingService.setLoadingText(locale.baseText('settings.sourceControl.loading.connecting'));
try {
await sourceControlStore.savePreferences({
authorName: sourceControlStore.preferences.authorName,
authorEmail: sourceControlStore.preferences.authorEmail,
repositoryUrl: sourceControlStore.preferences.repositoryUrl,
});
await sourceControlStore.getBranches();
@ -115,9 +111,6 @@ onMounted(async () => {
const formValidationStatus = reactive<Record<string, boolean>>({
repoUrl: false,
authorName: false,
authorEmail: false,
branchName: false,
});
function onValidate(key: string, value: boolean) {
@ -135,28 +128,9 @@ const repoUrlValidationRules: Array<Rule | RuleGroup> = [
},
];
const authorNameValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
const authorEmailValidationRules: Array<Rule | RuleGroup> = [
{ name: 'REQUIRED' },
{
name: 'MATCH_REGEX',
config: {
regex: VALID_EMAIL_REGEX,
message: locale.baseText('settings.sourceControl.authorEmailInvalid'),
},
},
];
const validForConnection = computed(() => formValidationStatus.repoUrl);
const branchNameValidationRules: Array<Rule | RuleGroup> = [{ name: 'REQUIRED' }];
const validForConnection = computed(
() =>
formValidationStatus.repoUrl &&
formValidationStatus.authorName &&
formValidationStatus.authorEmail,
);
async function refreshSshKey() {
try {
const confirmation = await message.confirm(
@ -205,7 +179,7 @@ const refreshBranches = async () => {
<n8n-callout theme="secondary" icon="info-circle" class="mt-2xl mb-l">
<i18n path="settings.sourceControl.description">
<template #link>
<a :href="sourceControlDocsSetupUrl" target="_blank">
<a :href="locale.baseText('settings.sourceControl.docs.url')" target="_blank">
{{ locale.baseText('settings.sourceControl.description.link') }}
</a>
</template>
@ -241,35 +215,6 @@ const refreshBranches = async () => {
>
</div>
</div>
<div :class="[$style.group, $style.groupFlex]">
<div>
<label for="authorName">{{ locale.baseText('settings.sourceControl.authorName') }}</label>
<n8n-form-input
label
id="authorName"
name="authorName"
validateOnBlur
:validationRules="authorNameValidationRules"
v-model="sourceControlStore.preferences.authorName"
@validate="(value) => onValidate('authorName', value)"
/>
</div>
<div>
<label for="authorEmail">{{
locale.baseText('settings.sourceControl.authorEmail')
}}</label>
<n8n-form-input
label
type="email"
id="authorEmail"
name="authorEmail"
validateOnBlur
:validationRules="authorEmailValidationRules"
v-model="sourceControlStore.preferences.authorEmail"
@validate="(value) => onValidate('authorEmail', value)"
/>
</div>
</div>
<div v-if="sourceControlStore.preferences.publicKey" :class="$style.group">
<label>{{ locale.baseText('settings.sourceControl.sshKey') }}</label>
<div :class="{ [$style.sshInput]: !isConnected }">
@ -293,9 +238,11 @@ const refreshBranches = async () => {
<n8n-notice type="info" class="mt-s">
<i18n path="settings.sourceControl.sshKeyDescription">
<template #link>
<a :href="sourceControlDocsSetupUrl" target="_blank">{{
locale.baseText('settings.sourceControl.sshKeyDescriptionLink')
}}</a>
<a
:href="locale.baseText('settings.sourceControl.docs.setup.ssh.url')"
target="_blank"
>{{ locale.baseText('settings.sourceControl.sshKeyDescriptionLink') }}</a
>
</template>
</i18n>
</n8n-notice>
@ -352,14 +299,9 @@ const refreshBranches = async () => {
v-model="sourceControlStore.preferences.branchReadOnly"
:class="$style.readOnly"
>
<i18n path="settings.sourceControl.readonly">
<i18n path="settings.sourceControl.protected">
<template #bold>
<strong>{{ locale.baseText('settings.sourceControl.readonly.bold') }}</strong>
</template>
<template #link>
<a :href="sourceControlDocsSetupUrl" target="_blank">
{{ locale.baseText('settings.sourceControl.readonly.link') }}
</a>
<strong>{{ locale.baseText('settings.sourceControl.protected.bold') }}</strong>
</template>
</i18n>
</n8n-checkbox>

View file

@ -13,6 +13,24 @@
@click:add="addWorkflow"
@update:filters="filters = $event"
>
<template #add-button="{ disabled }">
<n8n-tooltip :disabled="!readOnlyEnv">
<div>
<n8n-button
size="large"
block
:disabled="disabled"
@click="addWorkflow"
data-test-id="resources-list-add"
>
{{ $locale.baseText(`workflows.add`) }}
</n8n-button>
</div>
<template #content>
{{ $locale.baseText('mainSidebar.workflows.readOnlyEnv.tooltip') }}
</template>
</n8n-tooltip>
</template>
<template #default="{ data, updateItemSize }">
<workflow-card
data-test-id="resources-list-item"

View file

@ -80,8 +80,6 @@ describe('SettingsSourceControl', () => {
expect(connectButton).toBeDisabled();
const repoUrlInput = container.querySelector('input[name="repoUrl"]')!;
const authorName = container.querySelector('input[name="authorName"]')!;
const authorEmail = container.querySelector('input[name="authorEmail"]')!;
await userEvent.click(repoUrlInput);
await userEvent.type(repoUrlInput, 'git@github');
@ -91,21 +89,6 @@ describe('SettingsSourceControl', () => {
await userEvent.click(repoUrlInput);
await userEvent.type(repoUrlInput, '.com:john/n8n-data.git');
await userEvent.tab();
expect(connectButton).toBeDisabled();
await userEvent.click(authorName);
await userEvent.type(authorName, 'John Doe');
await userEvent.tab();
expect(connectButton).toBeDisabled();
await userEvent.click(authorEmail);
await userEvent.type(authorEmail, 'john@example.');
await userEvent.tab();
expect(connectButton).toBeDisabled();
await userEvent.click(authorEmail);
await userEvent.type(authorEmail, 'com');
await userEvent.tab();
await waitFor(() => expect(connectButton).toBeEnabled());
expect(queryByTestId('source-control-save-settings-button')).not.toBeInTheDocument();