mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 04:04:06 -08:00
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:
parent
bcfc5e717b
commit
fc7aa8bd66
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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}`);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -6,4 +6,5 @@ export interface ExportResult {
|
|||
name: string;
|
||||
}>;
|
||||
removedFiles?: string[];
|
||||
missingIds?: string[];
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -24,6 +24,4 @@ export class SourceControllPullOptions {
|
|||
force?: boolean;
|
||||
|
||||
variables?: { [key: string]: string };
|
||||
|
||||
importAfterPull?: boolean = true;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export interface SourceControlWorkflowVersionId {
|
||||
id: string;
|
||||
versionId: string;
|
||||
filename: string;
|
||||
name?: string;
|
||||
localId?: string;
|
||||
remoteId?: string;
|
||||
updatedAt?: string;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
256
packages/cli/test/unit/SourceControl.test.ts
Normal file
256
packages/cli/test/unit/SourceControl.test.ts
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -1451,8 +1451,6 @@ export type SamlPreferencesExtractedData = {
|
|||
export type SourceControlPreferences = {
|
||||
connected: boolean;
|
||||
repositoryUrl: string;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
branchName: string;
|
||||
branches: string[];
|
||||
branchReadOnly: boolean;
|
||||
|
|
|
@ -9,8 +9,6 @@ export function routesForSourceControl(server: Server) {
|
|||
const defaultSourceControlPreferences: SourceControlPreferences = {
|
||||
branchName: '',
|
||||
branches: [],
|
||||
authorName: '',
|
||||
authorEmail: '',
|
||||
repositoryUrl: '',
|
||||
branchReadOnly: false,
|
||||
branchColor: '#1d6acb',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
class="node-name"
|
||||
:value="node && node.name"
|
||||
:nodeType="nodeType"
|
||||
:isReadOnly="isReadOnly"
|
||||
:readOnly="isReadOnly"
|
||||
@input="nameChanged"
|
||||
></NodeTitle>
|
||||
<div v-if="isExecutable">
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
3
packages/editor-ui/src/event-bus/source-control.ts
Normal file
3
packages/editor-ui/src/event-bus/source-control.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { createEventBus } from 'n8n-design-system';
|
||||
|
||||
export const sourceControlEventBus = createEventBus();
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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": "We’re working on environments (as a paid feature)",
|
||||
"fakeDoor.settings.environments.actionBox.title.cloud": "We’re 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": "We’re working on this (as a paid feature)",
|
||||
"fakeDoor.settings.sso.actionBox.title.cloud": "We’re 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 don’t 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",
|
||||
|
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>) => {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue