diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index c97a95cd9e224..85cee05551553 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -34,6 +34,7 @@ import { SharedWorkflowRepository, TagRepository, UserRepository, + VariablesRepository, WebhookRepository, WorkflowRepository, WorkflowStatisticsRepository, @@ -178,6 +179,7 @@ export async function init( collections.SharedWorkflow = Container.get(SharedWorkflowRepository); collections.Tag = Container.get(TagRepository); collections.User = Container.get(UserRepository); + collections.Variables = Container.get(VariablesRepository); collections.Webhook = Container.get(WebhookRepository); collections.Workflow = Container.get(WorkflowRepository); collections.WorkflowStatistics = Container.get(WorkflowStatisticsRepository); diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index cb4ad810f7f87..092a06d133a97 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -56,6 +56,7 @@ import type { SharedWorkflowRepository, TagRepository, UserRepository, + VariablesRepository, WebhookRepository, WorkflowRepository, WorkflowStatisticsRepository, @@ -99,6 +100,7 @@ export interface IDatabaseCollections { SharedWorkflow: SharedWorkflowRepository; Tag: TagRepository; User: UserRepository; + Variables: VariablesRepository; Webhook: WebhookRepository; Workflow: WorkflowRepository; WorkflowStatistics: WorkflowStatisticsRepository; @@ -458,6 +460,7 @@ export interface IInternalHooksClass { }): Promise; onApiKeyCreated(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; onApiKeyDeleted(apiKeyDeletedData: { user: User; public_api: boolean }): Promise; + onVariableCreated(createData: { variable_type: string }): Promise; } export interface IVersionNotificationSettings { @@ -538,11 +541,15 @@ export interface IN8nUISettings { saml: boolean; logStreaming: boolean; advancedExecutionFilters: boolean; + variables: boolean; }; hideUsagePage: boolean; license: { environment: 'production' | 'staging'; }; + variables: { + limit: number; + }; } export interface IPersonalizationSurveyAnswers { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 0092a3d990d90..6a1ab61b64715 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -981,4 +981,8 @@ export class InternalHooks implements IInternalHooksClass { async onAuditGeneratedViaCli() { return this.telemetry.track('Instance generated security audit via CLI command'); } + + async onVariableCreated(createData: { variable_type: string }): Promise { + return this.telemetry.track('User created variable', createData); + } } diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 19d3e9510fdd7..e57625720c94a 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -4,7 +4,12 @@ import type { ILogger } from 'n8n-workflow'; import { getLogger } from './Logger'; import config from '@/config'; import * as Db from '@/Db'; -import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants'; +import { + LICENSE_FEATURES, + LICENSE_QUOTAS, + N8N_VERSION, + SETTINGS_LICENSE_CERT_KEY, +} from './constants'; import { Service } from 'typedi'; async function loadCertStr(): Promise { @@ -119,6 +124,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.ADVANCED_EXECUTION_FILTERS); } + isVariablesEnabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.VARIABLES); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } @@ -162,7 +171,11 @@ export class License { // Helper functions for computed data getTriggerLimit(): number { - return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number; + return (this.getFeatureValue(LICENSE_QUOTAS.TRIGGER_LIMIT) ?? -1) as number; + } + + getVariablesLimit(): number { + return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number; } getPlanName(): string { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 9081651b878b7..29439a72a33d4 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -156,7 +156,9 @@ import { import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers'; import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; +import { variablesController } from './environments/variables.controller'; import { LdapManager } from './Ldap/LdapManager.ee'; +import { getVariablesLimit, isVariablesEnabled } from '@/environments/enviromentHelpers'; import { getCurrentAuthenticationMethod } from './sso/ssoHelpers'; const exec = promisify(callbackExec); @@ -317,11 +319,15 @@ class Server extends AbstractServer { saml: false, logStreaming: false, advancedExecutionFilters: false, + variables: false, }, hideUsagePage: config.getEnv('hideUsagePage'), license: { environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging', }, + variables: { + limit: 0, + }, }; } @@ -347,6 +353,7 @@ class Server extends AbstractServer { ldap: isLdapEnabled(), saml: isSamlLicensed(), advancedExecutionFilters: isAdvancedExecutionFiltersEnabled(), + variables: isVariablesEnabled(), }); if (isLdapEnabled()) { @@ -363,6 +370,10 @@ class Server extends AbstractServer { }); } + if (isVariablesEnabled()) { + this.frontendSettings.variables.limit = getVariablesLimit(); + } + if (config.get('nodes.packagesMissing').length > 0) { this.frontendSettings.missingPackages = true; } @@ -540,6 +551,13 @@ class Server extends AbstractServer { } // ---------------------------------------- + // Variables + // ---------------------------------------- + + this.app.use(`/${this.restEndpoint}/variables`, variablesController); + + // ---------------------------------------- + // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index d2344c80bff0a..f958d3a49508d 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -1164,7 +1164,10 @@ export async function getBase( const webhookWaitingBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookWaiting'); const webhookTestBaseUrl = urlBaseWebhook + config.getEnv('endpoints.webhookTest'); - const encryptionKey = await UserSettings.getEncryptionKey(); + const [encryptionKey, variables] = await Promise.all([ + UserSettings.getEncryptionKey(), + WorkflowHelpers.getVariables(), + ]); return { credentialsHelper: new CredentialsHelper(encryptionKey), @@ -1179,6 +1182,7 @@ export async function getBase( executionTimeoutTimestamp, userId, setExecutionStatus, + variables, }; } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index e92d38603d102..4f8b121bbb041 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -562,3 +562,12 @@ export function validateWorkflowCredentialUsage( return newWorkflowVersion; } + +export async function getVariables(): Promise { + return Object.freeze( + (await Db.collections.Variables.find()).reduce((prev, curr) => { + prev[curr.key] = curr.value; + return prev; + }, {} as IDataObject), + ); +} diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 82f87dc13c048..938739069aa2a 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -74,6 +74,12 @@ export enum LICENSE_FEATURES { SAML = 'feat:saml', LOG_STREAMING = 'feat:logStreaming', ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', + VARIABLES = 'feat:variables', +} + +export enum LICENSE_QUOTAS { + TRIGGER_LIMIT = 'quota:activeWorkflows', + VARIABLES_LIMIT = 'quota:maxVariables', } export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6'; diff --git a/packages/cli/src/databases/entities/Variables.ts b/packages/cli/src/databases/entities/Variables.ts new file mode 100644 index 0000000000000..64eef5a9fc8f9 --- /dev/null +++ b/packages/cli/src/databases/entities/Variables.ts @@ -0,0 +1,16 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; + +@Entity() +export class Variables { + @PrimaryGeneratedColumn() + id: number; + + @Column('text') + key: string; + + @Column('text', { default: 'string' }) + type: string; + + @Column('text') + value: string; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 9440a96b24e24..8e2fdd75872ca 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -12,6 +12,7 @@ import { SharedCredentials } from './SharedCredentials'; import { SharedWorkflow } from './SharedWorkflow'; import { TagEntity } from './TagEntity'; import { User } from './User'; +import { Variables } from './Variables'; import { WebhookEntity } from './WebhookEntity'; import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowTagMapping } from './WorkflowTagMapping'; @@ -32,6 +33,7 @@ export const entities = { SharedWorkflow, TagEntity, User, + Variables, WebhookEntity, WorkflowEntity, WorkflowTagMapping, diff --git a/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts new file mode 100644 index 0000000000000..1e35fe5574518 --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1677501636753-CreateVariables.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636753 implements MigrationInterface { + name = 'CreateVariables1677501636753'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id int(11) auto_increment NOT NULL PRIMARY KEY, + \`key\` VARCHAR(50) NOT NULL, + \`type\` VARCHAR(50) DEFAULT 'string' NOT NULL, + value VARCHAR(255) NULL, + UNIQUE (\`key\`) + ) + ENGINE=InnoDB; + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index bb021d495b800..6d97fbceb14cb 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -35,6 +35,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable'; +import { CreateVariables1677501636753 } from './1677501636753-CreateVariables'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -74,4 +75,5 @@ export const mysqlMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236788851, CreateExecutionMetadataTable1679416281779, + CreateVariables1677501636753, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts new file mode 100644 index 0000000000000..106d844086907 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1677501636754-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636754 implements MigrationInterface { + name = 'CreateVariables1677501636754'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE public.variables ( + id serial4 NOT NULL PRIMARY KEY, + "key" varchar(50) NOT NULL, + "type" varchar(50) NOT NULL DEFAULT 'string', + value varchar(255) NULL, + UNIQUE ("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 0c0c33ca38624..175ff14848473 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -33,6 +33,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable'; +import { CreateVariables1677501636754 } from './1677501636754-CreateVariables'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -70,4 +71,5 @@ export const postgresMigrations = [ MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236854063, CreateExecutionMetadataTable1679416281778, + CreateVariables1677501636754, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts new file mode 100644 index 0000000000000..da6265defa9e1 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1677501636752-CreateVariables.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart, getTablePrefix } from '@db/utils/migrationHelpers'; +import config from '@/config'; + +export class CreateVariables1677501636752 implements MigrationInterface { + name = 'CreateVariables1677501636752'; + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(` + CREATE TABLE ${tablePrefix}variables ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "key" TEXT NOT NULL, + "type" TEXT NOT NULL DEFAULT ('string'), + value TEXT, + UNIQUE("key") + ); + `); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE ${tablePrefix}variables;`); + + logMigrationEnd(this.name); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 2d1d3e6709201..b7e324823fb86 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -32,6 +32,7 @@ import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToE import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable'; +import { CreateVariables1677501636752 } from './1677501636752-CreateVariables'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -67,6 +68,7 @@ const sqliteMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677237073720, + CreateVariables1677501636752, CreateExecutionMetadataTable1679416281777, ]; diff --git a/packages/cli/src/databases/repositories/index.ts b/packages/cli/src/databases/repositories/index.ts index e1e7cb02ed17f..04a11369c64f4 100644 --- a/packages/cli/src/databases/repositories/index.ts +++ b/packages/cli/src/databases/repositories/index.ts @@ -12,6 +12,7 @@ export { SharedCredentialsRepository } from './sharedCredentials.repository'; export { SharedWorkflowRepository } from './sharedWorkflow.repository'; export { TagRepository } from './tag.repository'; export { UserRepository } from './user.repository'; +export { VariablesRepository } from './variables.repository'; export { WebhookRepository } from './webhook.repository'; export { WorkflowRepository } from './workflow.repository'; export { WorkflowStatisticsRepository } from './workflowStatistics.repository'; diff --git a/packages/cli/src/databases/repositories/variables.repository.ts b/packages/cli/src/databases/repositories/variables.repository.ts new file mode 100644 index 0000000000000..d787a8b98431e --- /dev/null +++ b/packages/cli/src/databases/repositories/variables.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from 'typeorm'; +import { Variables } from '../entities/Variables'; + +@Service() +export class VariablesRepository extends Repository { + constructor(dataSource: DataSource) { + super(Variables, dataSource.manager); + } +} diff --git a/packages/cli/src/environments/enviromentHelpers.ts b/packages/cli/src/environments/enviromentHelpers.ts new file mode 100644 index 0000000000000..d7cc12249264b --- /dev/null +++ b/packages/cli/src/environments/enviromentHelpers.ts @@ -0,0 +1,26 @@ +import { License } from '@/License'; +import Container from 'typedi'; + +export function isVariablesEnabled(): boolean { + const license = Container.get(License); + return license.isVariablesEnabled(); +} + +export function canCreateNewVariable(variableCount: number): boolean { + if (!isVariablesEnabled()) { + return false; + } + const license = Container.get(License); + // This defaults to -1 which is what we want if we've enabled + // variables via the config + const limit = license.getVariablesLimit(); + if (limit === -1) { + return true; + } + return limit > variableCount; +} + +export function getVariablesLimit(): number { + const license = Container.get(License); + return license.getVariablesLimit(); +} diff --git a/packages/cli/src/environments/variables.controller.ee.ts b/packages/cli/src/environments/variables.controller.ee.ts new file mode 100644 index 0000000000000..3be05f32855d0 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ee.ts @@ -0,0 +1,79 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { + VariablesLicenseError, + EEVariablesService, + VariablesValidationError, +} from './variables.service.ee'; +import { isVariablesEnabled } from './enviromentHelpers'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const EEVariablesController = express.Router(); + +/** + * Initialize Logger if needed + */ +EEVariablesController.use((req, res, next) => { + if (!isVariablesEnabled()) { + next('router'); + return; + } + + next(); +}); + +EEVariablesController.post( + '/', + ResponseHelper.send(async (req: VariablesRequest.Create) => { + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.create(variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); + +EEVariablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Update) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to update a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + const variable = req.body; + delete variable.id; + try { + return await EEVariablesService.update(id, variable); + } catch (error) { + if (error instanceof VariablesLicenseError) { + throw new ResponseHelper.BadRequestError(error.message); + } else if (error instanceof VariablesValidationError) { + throw new ResponseHelper.BadRequestError(error.message); + } + throw error; + } + }), +); diff --git a/packages/cli/src/environments/variables.controller.ts b/packages/cli/src/environments/variables.controller.ts new file mode 100644 index 0000000000000..931df7784d8f1 --- /dev/null +++ b/packages/cli/src/environments/variables.controller.ts @@ -0,0 +1,82 @@ +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import { getLogger } from '@/Logger'; +import * as ResponseHelper from '@/ResponseHelper'; +import type { VariablesRequest } from '@/requests'; +import { VariablesService } from './variables.service'; +import { EEVariablesController } from './variables.controller.ee'; + +export const variablesController = express.Router(); + +variablesController.use('/', EEVariablesController); + +/** + * Initialize Logger if needed + */ +variablesController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +variablesController.use(EEVariablesController); + +variablesController.get( + '/', + ResponseHelper.send(async () => { + return VariablesService.getAll(); + }), +); + +variablesController.post( + '/', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.get( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Get) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + const variable = await VariablesService.get(id); + if (variable === null) { + throw new ResponseHelper.NotFoundError(`Variable with id ${req.params.id} not found`); + } + return variable; + }), +); + +variablesController.patch( + '/:id(\\d+)', + ResponseHelper.send(async () => { + throw new ResponseHelper.BadRequestError('No variables license found'); + }), +); + +variablesController.delete( + '/:id(\\d+)', + ResponseHelper.send(async (req: VariablesRequest.Delete) => { + const id = parseInt(req.params.id); + if (isNaN(id)) { + throw new ResponseHelper.BadRequestError('Invalid variable id ' + req.params.id); + } + if (req.user.globalRole.name !== 'owner') { + LoggerProxy.info('Attempt to delete a variable blocked due to lack of permissions', { + id, + userId: req.user.id, + }); + throw new ResponseHelper.AuthError('Unauthorized'); + } + await VariablesService.delete(id); + + return true; + }), +); diff --git a/packages/cli/src/environments/variables.service.ee.ts b/packages/cli/src/environments/variables.service.ee.ts new file mode 100644 index 0000000000000..b5c48dcef08d7 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ee.ts @@ -0,0 +1,45 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; +import { InternalHooks } from '@/InternalHooks'; +import Container from 'typedi'; +import { canCreateNewVariable } from './enviromentHelpers'; +import { VariablesService } from './variables.service'; + +export class VariablesLicenseError extends Error {} +export class VariablesValidationError extends Error {} + +export class EEVariablesService extends VariablesService { + static async getCount(): Promise { + return collections.Variables.count(); + } + + static validateVariable(variable: Omit): void { + if (variable.key.length > 50) { + throw new VariablesValidationError('key cannot be longer than 50 characters'); + } + 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) { + throw new VariablesValidationError('value cannot be longer than 255 characters'); + } + } + + static async create(variable: Omit): Promise { + if (!canCreateNewVariable(await this.getCount())) { + throw new VariablesLicenseError('Variables limit reached'); + } + this.validateVariable(variable); + + void Container.get(InternalHooks).onVariableCreated({ variable_type: variable.type }); + return collections.Variables.save(variable); + } + + static async update(id: number, variable: Omit): Promise { + this.validateVariable(variable); + + await collections.Variables.update(id, variable); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return (await this.get(id))!; + } +} diff --git a/packages/cli/src/environments/variables.service.ts b/packages/cli/src/environments/variables.service.ts new file mode 100644 index 0000000000000..646f9368f2382 --- /dev/null +++ b/packages/cli/src/environments/variables.service.ts @@ -0,0 +1,20 @@ +import type { Variables } from '@/databases/entities/Variables'; +import { collections } from '@/Db'; + +export class VariablesService { + static async getAll(): Promise { + return collections.Variables.find(); + } + + static async getCount(): Promise { + return collections.Variables.count(); + } + + static async get(id: number): Promise { + return collections.Variables.findOne({ where: { id } }); + } + + static async delete(id: number): Promise { + await collections.Variables.delete(id); + } +} diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 2c33800ec571f..af1fdae591b43 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -16,6 +16,7 @@ import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfac import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import type { UserManagementMailer } from '@/UserManagement/email'; +import type { Variables } from '@db/entities/Variables'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -386,3 +387,17 @@ export type BinaryDataRequest = AuthenticatedRequest< mimeType?: string; } >; + +// ---------------------------------- +// /variables +// ---------------------------------- +// +export declare namespace VariablesRequest { + type CreateUpdatePayload = Omit & { id?: unknown }; + + type GetAll = AuthenticatedRequest; + type Get = AuthenticatedRequest<{ id: string }, {}, {}, {}>; + type Create = AuthenticatedRequest<{}, {}, CreateUpdatePayload, {}>; + type Update = AuthenticatedRequest<{ id: string }, {}, CreateUpdatePayload, {}>; + type Delete = Get; +} diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 2d38a84ef3975..95e7303010622 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -489,6 +489,33 @@ export async function getWorkflowSharing(workflow: WorkflowEntity) { }); } +// ---------------------------------- +// variables +// ---------------------------------- + +export async function createVariable(key: string, value: string) { + return Db.collections.Variables.save({ + key, + value, + }); +} + +export async function getVariableByKey(key: string) { + return Db.collections.Variables.findOne({ + where: { + key, + }, + }); +} + +export async function getVariableById(id: number) { + return Db.collections.Variables.findOne({ + where: { + id, + }, + }); +} + // ---------------------------------- // connection options // ---------------------------------- diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 97bb13843ddbd..3fa94aec536db 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -24,7 +24,8 @@ type EndpointGroup = | 'ldap' | 'saml' | 'eventBus' - | 'license'; + | 'license' + | 'variables'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 619d03f9b7697..a2499b8c8d60c 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -73,6 +73,7 @@ import { v4 as uuid } from 'uuid'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { PostHogClient } from '@/posthog'; +import { variablesController } from '@/environments/variables.controller'; import { LdapManager } from '@/Ldap/LdapManager.ee'; import { handleLdapInit } from '@/Ldap/helpers'; import { Push } from '@/push'; @@ -151,6 +152,7 @@ export async function initTestServer({ credentials: { controller: credentialsController, path: 'credentials' }, workflows: { controller: workflowsController, path: 'workflows' }, license: { controller: licenseController, path: 'license' }, + variables: { controller: variablesController, path: 'variables' }, }; if (enablePublicAPI) { @@ -268,7 +270,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { const routerEndpoints: EndpointGroup[] = []; const functionEndpoints: EndpointGroup[] = []; - const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license']; + const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license', 'variables']; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), diff --git a/packages/cli/test/integration/variables.test.ts b/packages/cli/test/integration/variables.test.ts new file mode 100644 index 0000000000000..46e0814622555 --- /dev/null +++ b/packages/cli/test/integration/variables.test.ts @@ -0,0 +1,379 @@ +import type { Application } from 'express'; + +import type { User } from '@/databases/entities/User'; +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils'; + +import type { AuthAgent } from './shared/types'; +import type { ClassLike, MockedClass } from 'jest-mock'; +import { License } from '@/License'; + +// mock that credentialsSharing is not enabled +let app: Application; +let ownerUser: User; +let memberUser: User; +let authAgent: AuthAgent; +let variablesSpy: jest.SpyInstance; +let licenseLike = { + isVariablesEnabled: jest.fn().mockReturnValue(true), + getVariablesLimit: jest.fn().mockReturnValue(-1), +}; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['variables'] }); + + utils.initConfigFile(); + utils.mockInstance(License, licenseLike); + + ownerUser = await testDb.createOwner(); + memberUser = await testDb.createUser(); + + authAgent = utils.createAuthAgent(app); +}); + +beforeEach(async () => { + await testDb.truncate(['Variables']); + licenseLike.isVariablesEnabled.mockReturnValue(true); + licenseLike.getVariablesLimit.mockReturnValue(-1); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +// ---------------------------------------- +// GET /variables - fetch all variables +// ---------------------------------------- + +test('GET /variables should return all variables for an owner', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(ownerUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +test('GET /variables should return all variables for a member', async () => { + await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response = await authAgent(memberUser).get('/variables'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); +}); + +// ---------------------------------------- +// GET /variables/:id - get a single variable +// ---------------------------------------- + +test('GET /variables/:id should return a single variable for an owner', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(ownerUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(ownerUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +test('GET /variables/:id should return a single variable for a member', async () => { + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + ]); + + const response1 = await authAgent(memberUser).get(`/variables/${var1.id}`); + expect(response1.statusCode).toBe(200); + expect(response1.body.data.key).toBe('test1'); + + const response2 = await authAgent(memberUser).get(`/variables/${var2.id}`); + expect(response2.statusCode).toBe(200); + expect(response2.body.data.key).toBe('test2'); +}); + +// ---------------------------------------- +// POST /variables - create a new variable +// ---------------------------------------- + +test('POST /variables should create a new credential and return it for an owner', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toCreate.key); + expect(response.body.data.value).toBe(toCreate.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toCreate.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toCreate.key); + expect(byId!.value).toBe(toCreate.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toCreate.value); +}); + +test('POST /variables should not create a new credential and return it for a member', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test("POST /variables should not create a new credential and return it if the instance doesn't have a license", async () => { + licenseLike.isVariablesEnabled.mockReturnValue(false); + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); + + const byKey = await testDb.getVariableByKey(toCreate.key); + expect(byKey).toBeNull(); +}); + +test('POST /variables should fail to create a new credential and if one with the same key exists', async () => { + const toCreate = { + key: 'create1', + value: 'createvalue1', + }; + await testDb.createVariable(toCreate.key, toCreate.value); + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should not fail if variable limit not reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 3) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(200); + expect(response.body.data?.key).toBe(toCreate.key); + expect(response.body.data?.value).toBe(toCreate.value); +}); + +test('POST /variables should fail if variable limit reached', async () => { + licenseLike.getVariablesLimit.mockReturnValue(5); + let i = 1; + let toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + while (i < 6) { + await testDb.createVariable(toCreate.key, toCreate.value); + i++; + toCreate = { + key: `create${i}`, + value: `createvalue${i}`, + }; + } + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if key too long', async () => { + const toCreate = { + // 51 'a's + key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test('POST /variables should fail if value too long', async () => { + const toCreate = { + key: 'key', + // 256 'a's + value: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +test("POST /variables should fail if key contain's prohibited characters", async () => { + const toCreate = { + // 51 'a's + key: 'te$t', + value: 'value', + }; + const response = await authAgent(ownerUser).post('/variables').send(toCreate); + expect(response.statusCode).toBe(400); + expect(response.body.data?.key).not.toBe(toCreate.key); + expect(response.body.data?.value).not.toBe(toCreate.value); +}); + +// ---------------------------------------- +// PATCH /variables/:id - change a variable +// ---------------------------------------- + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should modify existing credential if use is an owner', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(ownerUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(200); + expect(response.body.data.key).toBe(toModify.key); + expect(response.body.data.value).toBe(toModify.value); + + const [byId, byKey] = await Promise.all([ + testDb.getVariableById(response.body.data.id), + testDb.getVariableByKey(toModify.key), + ]); + + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(toModify.key); + expect(byId!.value).toBe(toModify.value); + + expect(byKey).not.toBeNull(); + expect(byKey!.id).toBe(response.body.data.id); + expect(byKey!.value).toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if use is a member', async () => { + const variable = await testDb.createVariable('test1', 'value1'); + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const response = await authAgent(memberUser).patch(`/variables/${variable.id}`).send(toModify); + expect(response.statusCode).toBe(401); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(variable.id); + expect(byId).not.toBeNull(); + expect(byId!.key).not.toBe(toModify.key); + expect(byId!.value).not.toBe(toModify.value); +}); + +test('PATCH /variables/:id should not modify existing credential if one with the same key exists', async () => { + const toModify = { + key: 'create1', + value: 'createvalue1', + }; + const [var1, var2] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable(toModify.key, toModify.value), + ]); + const response = await authAgent(ownerUser).patch(`/variables/${var1.id}`).send(toModify); + expect(response.statusCode).toBe(500); + expect(response.body.data?.key).not.toBe(toModify.key); + expect(response.body.data?.value).not.toBe(toModify.value); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + expect(byId!.key).toBe(var1.key); + expect(byId!.value).toBe(var1.value); +}); + +// ---------------------------------------- +// DELETE /variables/:id - change a variable +// ---------------------------------------- + +test('DELETE /variables/:id should delete a single credential for an owner', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(ownerUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(200); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).toBeNull(); + + const getResponse = await authAgent(ownerUser).get('/variables'); + expect(getResponse.body.data.length).toBe(2); +}); + +test('DELETE /variables/:id should not delete a single credential for a member', async () => { + const [var1, var2, var3] = await Promise.all([ + testDb.createVariable('test1', 'value1'), + testDb.createVariable('test2', 'value2'), + testDb.createVariable('test3', 'value3'), + ]); + + const delResponse = await authAgent(memberUser).delete(`/variables/${var1.id}`); + expect(delResponse.statusCode).toBe(401); + + const byId = await testDb.getVariableById(var1.id); + expect(byId).not.toBeNull(); + + const getResponse = await authAgent(memberUser).get('/variables'); + expect(getResponse.body.data.length).toBe(3); +}); diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index bb4927d1762b1..efe6fe22960c8 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -121,6 +121,9 @@ jest.mock('@/Db', () => { clear: jest.fn(), delete: jest.fn(), }, + Variables: { + find: jest.fn(() => []), + }, }, }; }); diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 1962b38d3dbe5..14bb8748ae830 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -1686,6 +1686,7 @@ export function getAdditionalKeys( } : undefined, }, + $vars: additionalData.variables, // deprecated $executionId: executionId, diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue index 24523b783aab8..87c8ddcd33759 100644 --- a/packages/design-system/src/components/N8nDatatable/Datatable.vue +++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue @@ -1,10 +1,6 @@ diff --git a/packages/design-system/src/plugins/n8nComponents.ts b/packages/design-system/src/plugins/n8nComponents.ts index 65b8f0a6c9a7b..6c182b53f2cba 100644 --- a/packages/design-system/src/plugins/n8nComponents.ts +++ b/packages/design-system/src/plugins/n8nComponents.ts @@ -10,8 +10,10 @@ import N8nButton from '../components/N8nButton'; import { N8nElButton } from '../components/N8nButton/overrides'; import N8nCallout from '../components/N8nCallout'; import N8nCard from '../components/N8nCard'; +import N8nDatatable from '../components/N8nDatatable'; import N8nFormBox from '../components/N8nFormBox'; import N8nFormInputs from '../components/N8nFormInputs'; +import N8nFormInput from '../components/N8nFormInput'; import N8nHeading from '../components/N8nHeading'; import N8nIcon from '../components/N8nIcon'; import N8nIconButton from '../components/N8nIconButton'; @@ -61,8 +63,10 @@ const n8nComponentsPlugin: PluginObject<{}> = { app.component('el-button', N8nElButton); app.component('n8n-callout', N8nCallout); app.component('n8n-card', N8nCard); + app.component('n8n-datatable', N8nDatatable); app.component('n8n-form-box', N8nFormBox); app.component('n8n-form-inputs', N8nFormInputs); + app.component('n8n-form-input', N8nFormInput); app.component('n8n-icon', N8nIcon); app.component('n8n-icon-button', N8nIconButton); app.component('n8n-info-tip', N8nInfoTip); diff --git a/packages/design-system/src/components/N8nDatatable/mixins.ts b/packages/design-system/src/types/datatable.ts similarity index 71% rename from packages/design-system/src/components/N8nDatatable/mixins.ts rename to packages/design-system/src/types/datatable.ts index 907b86c98c9c8..aa568483424cd 100644 --- a/packages/design-system/src/components/N8nDatatable/mixins.ts +++ b/packages/design-system/src/types/datatable.ts @@ -12,5 +12,7 @@ export interface DatatableColumn { id: string | number; path: string; label: string; - render: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType; + classes?: string[]; + width?: string; + render?: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType; } diff --git a/packages/design-system/src/types/form.ts b/packages/design-system/src/types/form.ts index c17389496bede..40012fecfa7fb 100644 --- a/packages/design-system/src/types/form.ts +++ b/packages/design-system/src/types/form.ts @@ -11,7 +11,7 @@ export type IValidator = { validate: ( value: Validatable, config: T, - ) => false | { messageKey: string; options?: unknown } | null; + ) => false | { messageKey: string; message?: string; options?: unknown } | null; }; export type FormState = { diff --git a/packages/design-system/src/types/index.ts b/packages/design-system/src/types/index.ts index 90c6bebafa173..9fa2387dc47b7 100644 --- a/packages/design-system/src/types/index.ts +++ b/packages/design-system/src/types/index.ts @@ -1,4 +1,5 @@ export * from './button'; +export * from './datatable'; export * from './form'; export * from './menu'; export * from './router'; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 4ff8dd40930a4..4633fc6d877f3 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -47,6 +47,7 @@ "canvas-confetti": "^1.6.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", + "copy-to-clipboard": "^3.3.3", "dateformat": "^3.0.3", "esprima-next": "5.8.4", "fast-json-stable-stringify": "^2.1.0", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 1cba1e76eac93..00c80ca47fa8f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1470,6 +1470,16 @@ export type NodeAuthenticationOption = { displayOptions?: IDisplayOptions; }; +export interface EnvironmentVariable { + id: number; + key: string; + value: string; +} + +export interface TemporaryEnvironmentVariable extends Omit { + id: string; +} + export type ExecutionFilterMetadata = { key: string; value: string; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/index.ts b/packages/editor-ui/src/__tests__/server/endpoints/index.ts index 0b82fa233b53c..f98c9c3823292 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/index.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/index.ts @@ -1,12 +1,16 @@ +import { Server } from 'miragejs'; import { routesForUsers } from './user'; import { routesForCredentials } from './credential'; -import { Server } from 'miragejs'; -import { routesForCredentialTypes } from '@/__tests__/server/endpoints/credentialType'; +import { routesForCredentialTypes } from './credentialType'; +import { routesForVariables } from './variable'; +import { routesForSettings } from './settings'; const endpoints: Array<(server: Server) => void> = [ routesForCredentials, routesForCredentialTypes, routesForUsers, + routesForVariables, + routesForSettings, ]; export { endpoints }; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts new file mode 100644 index 0000000000000..374e548d8841c --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts @@ -0,0 +1,75 @@ +import { Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; +import { IN8nUISettings, ISettingsState } from '@/Interface'; + +const defaultSettings: IN8nUISettings = { + allowedModules: {}, + communityNodesEnabled: false, + defaultLocale: '', + endpointWebhook: '', + endpointWebhookTest: '', + enterprise: { + variables: true, + }, + executionMode: '', + executionTimeout: 0, + hideUsagePage: false, + hiringBannerEnabled: false, + instanceId: '', + isNpmAvailable: false, + license: { environment: 'development' }, + logLevel: 'info', + maxExecutionTimeout: 0, + oauthCallbackUrls: { oauth1: '', oauth2: '' }, + onboardingCallPromptEnabled: false, + personalizationSurveyEnabled: false, + posthog: { + apiHost: '', + apiKey: '', + autocapture: false, + debug: false, + disableSessionRecording: false, + enabled: false, + }, + publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } }, + pushBackend: 'websocket', + saveDataErrorExecution: '', + saveDataSuccessExecution: '', + saveManualExecutions: false, + sso: { + ldap: { loginEnabled: false, loginLabel: '' }, + saml: { loginEnabled: false, loginLabel: '' }, + }, + telemetry: { + enabled: false, + }, + templates: { enabled: false, host: '' }, + timezone: '', + urlBaseEditor: '', + urlBaseWebhook: '', + userManagement: { + enabled: true, + showSetupOnFirstLoad: true, + smtpSetup: true, + }, + versionCli: '', + versionNotifications: { + enabled: true, + endpoint: '', + infoUrl: '', + }, + workflowCallerPolicyDefaultOption: 'any', + workflowTagsDisabled: false, +}; + +export function routesForSettings(server: Server) { + server.get('/rest/settings', (schema: AppSchema) => { + return new Response( + 200, + {}, + { + data: defaultSettings, + }, + ); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/endpoints/user.ts b/packages/editor-ui/src/__tests__/server/endpoints/user.ts index 7822231bac02d..0400b47b96ba7 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/user.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/user.ts @@ -7,4 +7,12 @@ export function routesForUsers(server: Server) { return new Response(200, {}, { data }); }); + + server.get('/rest/login', (schema: AppSchema) => { + const model = schema.findBy('user', { + isDefaultUser: true, + }); + + return new Response(200, {}, { data: model?.attrs }); + }); } diff --git a/packages/editor-ui/src/__tests__/server/endpoints/variable.ts b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts new file mode 100644 index 0000000000000..bbe3b87eb1eb9 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/variable.ts @@ -0,0 +1,41 @@ +import { Request, Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; +import { jsonParse } from 'n8n-workflow'; +import { EnvironmentVariable } from '@/Interface'; + +export function routesForVariables(server: Server) { + server.get('/rest/variables', (schema: AppSchema) => { + const { models: data } = schema.all('variable'); + + return new Response(200, {}, { data }); + }); + + server.post('/rest/variables', (schema: AppSchema, request: Request) => { + const data = schema.create('variable', jsonParse(request.requestBody)); + + return new Response(200, {}, { data }); + }); + + server.patch('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const data: EnvironmentVariable = jsonParse(request.requestBody); + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.update(data); + } + + return new Response(200, {}, { data: model?.attrs }); + }); + + server.delete('/rest/variables/:id', (schema: AppSchema, request: Request) => { + const id = request.params.id; + + const model = schema.find('variable', id); + if (model) { + model.destroy(); + } + + return new Response(200, {}, {}); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/factories/index.ts b/packages/editor-ui/src/__tests__/server/factories/index.ts index 181ff9b9a1153..9b5155c28f1e8 100644 --- a/packages/editor-ui/src/__tests__/server/factories/index.ts +++ b/packages/editor-ui/src/__tests__/server/factories/index.ts @@ -1,13 +1,16 @@ import { userFactory } from './user'; import { credentialFactory } from './credential'; import { credentialTypeFactory } from './credentialType'; +import { variableFactory } from './variable'; export * from './user'; export * from './credential'; export * from './credentialType'; +export * from './variable'; export const factories = { credential: credentialFactory, credentialType: credentialTypeFactory, user: userFactory, + variable: variableFactory, }; diff --git a/packages/editor-ui/src/__tests__/server/factories/variable.ts b/packages/editor-ui/src/__tests__/server/factories/variable.ts new file mode 100644 index 0000000000000..948e2578888fd --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/variable.ts @@ -0,0 +1,15 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import type { EnvironmentVariable } from '@/Interface'; + +export const variableFactory = Factory.extend({ + id(i: number) { + return i; + }, + key() { + return `${faker.lorem.word()}`.toUpperCase(); + }, + value() { + return faker.internet.password(10); + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/index.ts b/packages/editor-ui/src/__tests__/server/index.ts index edff6894e7acc..7eb78caacad29 100644 --- a/packages/editor-ui/src/__tests__/server/index.ts +++ b/packages/editor-ui/src/__tests__/server/index.ts @@ -10,6 +10,8 @@ export function setupServer() { seeds(server) { server.createList('credentialType', 8); server.create('user', { + firstName: 'Nathan', + lastName: 'Doe', isDefaultUser: true, }); }, diff --git a/packages/editor-ui/src/__tests__/server/models/index.ts b/packages/editor-ui/src/__tests__/server/models/index.ts index 310b832a4d00d..6b9f39327b9bf 100644 --- a/packages/editor-ui/src/__tests__/server/models/index.ts +++ b/packages/editor-ui/src/__tests__/server/models/index.ts @@ -1,9 +1,11 @@ import { UserModel } from './user'; import { CredentialModel } from './credential'; import { CredentialTypeModel } from './credentialType'; +import { VariableModel } from './variable'; export const models = { credential: CredentialModel, credentialType: CredentialTypeModel, user: UserModel, + variable: VariableModel, }; diff --git a/packages/editor-ui/src/__tests__/server/models/variable.ts b/packages/editor-ui/src/__tests__/server/models/variable.ts new file mode 100644 index 0000000000000..5677a4db9b980 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/variable.ts @@ -0,0 +1,5 @@ +import { EnvironmentVariable } from '@/Interface'; +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; + +export const VariableModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/api/environments.ee.ts b/packages/editor-ui/src/api/environments.ee.ts new file mode 100644 index 0000000000000..baf4280459e94 --- /dev/null +++ b/packages/editor-ui/src/api/environments.ee.ts @@ -0,0 +1,40 @@ +import { EnvironmentVariable, IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; +import { IDataObject } from 'n8n-workflow'; + +export async function getVariables(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/variables'); +} + +export async function getVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +): Promise { + return await makeRestApiRequest(context, 'GET', `/variables/${id}`); +} + +export async function createVariable( + context: IRestApiContext, + data: Omit, +) { + return await makeRestApiRequest(context, 'POST', '/variables', data as unknown as IDataObject); +} + +export async function updateVariable( + context: IRestApiContext, + { id, ...data }: EnvironmentVariable, +) { + return await makeRestApiRequest( + context, + 'PATCH', + `/variables/${id}`, + data as unknown as IDataObject, + ); +} + +export async function deleteVariable( + context: IRestApiContext, + { id }: { id: EnvironmentVariable['id'] }, +) { + return await makeRestApiRequest(context, 'DELETE', `/variables/${id}`); +} diff --git a/packages/editor-ui/src/components/CodeEdit.vue b/packages/editor-ui/src/components/CodeEdit.vue index 9c7d87065576d..8198196d48c4f 100644 --- a/packages/editor-ui/src/components/CodeEdit.vue +++ b/packages/editor-ui/src/components/CodeEdit.vue @@ -135,6 +135,7 @@ export default mixins(genericHelpers, workflowHelpers).extend({ '$mode', '$parameter', '$resumeWebhookUrl', + '$vars', '$workflow', '$now', '$today', diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts index d37e979499837..efebc8e76d7ac 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completer.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completer.ts @@ -13,6 +13,7 @@ import { luxonCompletions } from './completions/luxon.completions'; import { itemIndexCompletions } from './completions/itemIndex.completions'; import { itemFieldCompletions } from './completions/itemField.completions'; import { jsonFieldCompletions } from './completions/jsonField.completions'; +import { variablesCompletions } from './completions/variables.completions'; import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; import type { Extension } from '@codemirror/state'; @@ -24,6 +25,7 @@ export const completerExtension = mixins( requireCompletions, executionCompletions, workflowCompletions, + variablesCompletions, prevNodeCompletions, luxonCompletions, itemIndexCompletions, @@ -49,6 +51,7 @@ export const completerExtension = mixins( this.nodeSelectorCompletions, this.prevNodeCompletions, this.workflowCompletions, + this.variablesCompletions, this.executionCompletions, // luxon @@ -167,6 +170,7 @@ export const completerExtension = mixins( // core if (value === '$execution') return this.executionCompletions(context, variable); + if (value === '$vars') return this.variablesCompletions(context, variable); if (value === '$workflow') return this.workflowCompletions(context, variable); if (value === '$prevNode') return this.prevNodeCompletions(context, variable); diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts index bd14af28870de..a9401714ebce3 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/base.completions.ts @@ -67,6 +67,10 @@ export const baseCompletions = (Vue as CodeNodeEditorMixin).extend({ label: '$workflow', info: this.$locale.baseText('codeNodeEditor.completer.$workflow'), }, + { + label: '$vars', + info: this.$locale.baseText('codeNodeEditor.completer.$vars'), + }, { label: '$now', info: this.$locale.baseText('codeNodeEditor.completer.$now'), diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts new file mode 100644 index 0000000000000..dc60e9d96359d --- /dev/null +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/variables.completions.ts @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import { addVarType } from '../utils'; +import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete'; +import type { CodeNodeEditorMixin } from '../types'; +import { useEnvironmentsStore } from '@/stores'; + +const escape = (str: string) => str.replace('$', '\\$'); + +export const variablesCompletions = (Vue as CodeNodeEditorMixin).extend({ + methods: { + /** + * Complete `$workflow.` to `.id .name .active`. + */ + variablesCompletions(context: CompletionContext, matcher = '$vars'): CompletionResult | null { + const pattern = new RegExp(`${escape(matcher)}\..*`); + + const preCursor = context.matchBefore(pattern); + + if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + + const environmentsStore = useEnvironmentsStore(); + const options: Completion[] = environmentsStore.variables.map((variable) => ({ + label: `${matcher}.${variable.key}`, + info: variable.value, + })); + + return { + from: preCursor.from, + options: options.map(addVarType), + }; + }, + }, +}); diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 1b7f142ead108..8809d660a259a 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -226,6 +226,14 @@ export default mixins( position: 'top', activateOnRouteNames: [VIEWS.CREDENTIALS], }, + { + id: 'variables', + icon: 'variable', + label: this.$locale.baseText('mainSidebar.variables'), + customIconSize: 'medium', + position: 'top', + activateOnRouteNames: [VIEWS.VARIABLES], + }, { id: 'executions', icon: 'tasks', @@ -374,6 +382,12 @@ export default mixins( } break; } + case 'variables': { + if (this.$router.currentRoute.name !== VIEWS.VARIABLES) { + this.goToRoute({ name: VIEWS.VARIABLES }); + } + break; + } case 'executions': { if (this.$router.currentRoute.name !== VIEWS.EXECUTIONS) { this.goToRoute({ name: VIEWS.EXECUTIONS }); diff --git a/packages/editor-ui/src/components/VariablesRow.vue b/packages/editor-ui/src/components/VariablesRow.vue new file mode 100644 index 0000000000000..4b1264659d3a0 --- /dev/null +++ b/packages/editor-ui/src/components/VariablesRow.vue @@ -0,0 +1,272 @@ + + + + + diff --git a/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts new file mode 100644 index 0000000000000..ac87e260b0eed --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/VariablesRow.spec.ts @@ -0,0 +1,98 @@ +import VariablesRow from '../VariablesRow.vue'; +import { EnvironmentVariable } from '@/Interface'; +import { fireEvent, render } from '@testing-library/vue'; +import { createPinia, setActivePinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { afterAll, beforeAll } from 'vitest'; +import { useSettingsStore, useUsersStore } from '@/stores'; + +describe('VariablesRow', () => { + let server: ReturnType; + + beforeAll(() => { + server = setupServer(); + }); + + beforeEach(async () => { + setActivePinia(createPinia()); + + await useSettingsStore().getSettings(); + await useUsersStore().loginWithCookie(); + }); + + afterAll(() => { + server.shutdown(); + }); + + const stubs = ['n8n-tooltip']; + + const environmentVariable: EnvironmentVariable = { + id: 1, + key: 'key', + value: 'value', + }; + + it('should render correctly', () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + }, + stubs, + }); + + expect(wrapper.html()).toMatchSnapshot(); + expect(wrapper.container.querySelectorAll('td')).toHaveLength(4); + }); + + it('should show edit and delete buttons on hover', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-edit-button')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-delete-button')).toBeVisible(); + }); + + it('should show key and value inputs in edit mode', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + editing: true, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-key-input')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-key-input').querySelector('input')).toHaveValue( + environmentVariable.key, + ); + expect(wrapper.getByTestId('variable-row-value-input')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-value-input').querySelector('input')).toHaveValue( + environmentVariable.value, + ); + + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('should show cancel and save buttons in edit mode', async () => { + const wrapper = render(VariablesRow, { + props: { + data: environmentVariable, + editing: true, + }, + stubs, + }); + + await fireEvent.mouseEnter(wrapper.container); + + expect(wrapper.getByTestId('variable-row-cancel-button')).toBeVisible(); + expect(wrapper.getByTestId('variable-row-save-button')).toBeVisible(); + }); +}); diff --git a/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap b/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap new file mode 100644 index 0000000000000..8d8404c7e5f19 --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/__snapshots__/VariablesRow.spec.ts.snap @@ -0,0 +1,78 @@ +// Vitest Snapshot v1 + +exports[`VariablesRow > should render correctly 1`] = ` +" + +
key
+ + +
value
+ + +
+ $vars.key +
+ + +
+ +
+
+ +
+
+
+ +" +`; + +exports[`VariablesRow > should show key and value inputs in edit mode 1`] = ` +" + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + +
+
+ +
+
+ + + + + +
+
+ +
+
+ + +
+ $vars.key +
+ + +
+ +" +`; diff --git a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue index f66ee0a2ad889..84246d22dc2ef 100644 --- a/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue +++ b/packages/editor-ui/src/components/layouts/PageViewLayoutList.vue @@ -1,5 +1,18 @@ + + + + @@ -177,6 +196,7 @@ import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown. import { mapStores } from 'pinia'; import { useSettingsStore } from '@/stores/settings'; import { useUsersStore } from '@/stores/users'; +import { DatatableColumn } from 'n8n-design-system'; export interface IResource { id: string; @@ -213,13 +233,17 @@ export default mixins(showMessage, debounceHelper).extend({ type: String, default: '' as IResourceKeyType, }, + displayName: { + type: Function as PropType<(resource: IResource) => string>, + default: (resource: IResource) => resource.name, + }, resources: { type: Array, default: (): IResource[] => [], }, - itemSize: { - type: Number, - default: 80, + disabled: { + type: Boolean, + default: false, }, initialize: { type: Function as PropType<() => Promise>, @@ -240,13 +264,37 @@ export default mixins(showMessage, debounceHelper).extend({ type: Boolean, default: true, }, + showFiltersDropdown: { + type: Boolean, + default: true, + }, + sortFns: { + type: Object as PropType number>>, + default: (): Record number> => ({}), + }, + sortOptions: { + type: Array as PropType, + default: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'], + }, + type: { + type: String as PropType<'datatable' | 'list'>, + default: 'list', + }, + typeProps: { + type: Object as PropType<{ itemSize: number } | { columns: DatatableColumn[] }>, + default: () => ({ + itemSize: 0, + }), + }, }, data() { return { loading: true, isOwnerSubview: false, - sortBy: 'lastUpdated', + sortBy: this.sortOptions[0], hasFilters: false, + currentPage: 1, + rowsPerPage: 10 as number | '*', resettingFilters: false, EnterpriseEditionFeature, }; @@ -292,7 +340,7 @@ export default mixins(showMessage, debounceHelper).extend({ if (this.filters.search) { const searchString = this.filters.search.toLowerCase(); - matches = matches && resource.name.toLowerCase().includes(searchString); + matches = matches && this.displayName(resource).toLowerCase().includes(searchString); } if (this.additionalFiltersHandler) { @@ -305,15 +353,23 @@ export default mixins(showMessage, debounceHelper).extend({ return filtered.sort((a, b) => { switch (this.sortBy) { case 'lastUpdated': - return new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); + return this.sortFns['lastUpdated'] + ? this.sortFns['lastUpdated'](a, b) + : new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); case 'lastCreated': - return new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); + return this.sortFns['lastCreated'] + ? this.sortFns['lastCreated'](a, b) + : new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); case 'nameAsc': - return a.name.trim().localeCompare(b.name.trim()); + return this.sortFns['nameAsc'] + ? this.sortFns['nameAsc'](a, b) + : this.displayName(a).trim().localeCompare(this.displayName(b).trim()); case 'nameDesc': - return b.name.localeCompare(a.name); + return this.sortFns['nameDesc'] + ? this.sortFns['nameDesc'](a, b) + : this.displayName(b).trim().localeCompare(this.displayName(a).trim()); default: - return 0; + return this.sortFns[this.sortBy] ? this.sortFns[this.sortBy](a, b) : 0; } }); }, @@ -333,6 +389,12 @@ export default mixins(showMessage, debounceHelper).extend({ this.loading = false; this.$nextTick(this.focusSearchInput); }, + setCurrentPage(page: number) { + this.currentPage = page; + }, + setRowsPerPage(rowsPerPage: number | '*') { + this.rowsPerPage = rowsPerPage; + }, resetFilters() { Object.keys(this.filters).forEach((key) => { this.filters[key] = Array.isArray(this.filters[key]) ? [] : ''; @@ -418,7 +480,8 @@ export default mixins(showMessage, debounceHelper).extend({ 'filters.search'() { this.callDebounced('sendFiltersTelemetry', { debounceTime: 1000, trailing: true }, 'search'); }, - sortBy() { + sortBy(newValue) { + this.$emit('sort', newValue); this.sendSortingTelemetry(); }, }, @@ -446,6 +509,10 @@ export default mixins(showMessage, debounceHelper).extend({ //flex-direction: column; } +.listWrapper { + height: 100%; +} + .sort-and-filter { display: flex; flex-direction: row; @@ -460,4 +527,8 @@ export default mixins(showMessage, debounceHelper).extend({ .card-loading { height: 69px; } + +.datatable { + padding-bottom: var(--spacing-s); +} diff --git a/packages/editor-ui/src/composables/index.ts b/packages/editor-ui/src/composables/index.ts new file mode 100644 index 0000000000000..86ee35fe50793 --- /dev/null +++ b/packages/editor-ui/src/composables/index.ts @@ -0,0 +1,8 @@ +export * from './useCopyToClipboard'; +export * from './useExternalHooks'; +export * from './useGlobalLinkActions'; +export * from './useI18n'; +export * from './useMessage'; +export * from './useTelemetry'; +export * from './useToast'; +export * from './useUpgradeLink'; diff --git a/packages/editor-ui/src/composables/useCopyToClipboard.ts b/packages/editor-ui/src/composables/useCopyToClipboard.ts new file mode 100644 index 0000000000000..7e71370d3b79a --- /dev/null +++ b/packages/editor-ui/src/composables/useCopyToClipboard.ts @@ -0,0 +1,5 @@ +import copyToClipboard from 'copy-to-clipboard'; + +export function useCopyToClipboard(): (text: string) => void { + return copyToClipboard; +} diff --git a/packages/editor-ui/src/composables/useExternalHooks.ts b/packages/editor-ui/src/composables/useExternalHooks.ts new file mode 100644 index 0000000000000..ff3e3ab48123c --- /dev/null +++ b/packages/editor-ui/src/composables/useExternalHooks.ts @@ -0,0 +1,12 @@ +import { IExternalHooks } from '@/Interface'; +import { IDataObject } from 'n8n-workflow'; +import { useWebhooksStore } from '@/stores'; +import { runExternalHook } from '@/mixins/externalHooks'; + +export function useExternalHooks(): IExternalHooks { + return { + async run(eventName: string, metadata?: IDataObject): Promise { + return await runExternalHook.call(this, eventName, useWebhooksStore(), metadata); + }, + }; +} diff --git a/packages/editor-ui/src/composables/useI18n.ts b/packages/editor-ui/src/composables/useI18n.ts new file mode 100644 index 0000000000000..61d7bf807a50d --- /dev/null +++ b/packages/editor-ui/src/composables/useI18n.ts @@ -0,0 +1,5 @@ +import { i18n } from '@/plugins/i18n'; + +export function useI18n() { + return i18n; +} diff --git a/packages/editor-ui/src/composables/useMessage.ts b/packages/editor-ui/src/composables/useMessage.ts new file mode 100644 index 0000000000000..2a5dd6e8e7ac8 --- /dev/null +++ b/packages/editor-ui/src/composables/useMessage.ts @@ -0,0 +1,65 @@ +import type { ElMessageBoxOptions } from 'element-ui/types/message-box'; +import { Message, MessageBox } from 'element-ui'; + +export function useMessage() { + async function alert( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.alert(message, configOrTitle, resolvedConfig); + } + return await MessageBox.alert(message, resolvedConfig); + } + + async function confirm( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + distinguishCancelAndClose: true, + showClose: config?.showClose || false, + closeOnClickModal: false, + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.confirm(message, configOrTitle, resolvedConfig); + } + return await MessageBox.confirm(message, resolvedConfig); + } + + async function prompt( + message: string, + configOrTitle: string | ElMessageBoxOptions | undefined, + config: ElMessageBoxOptions | undefined, + ) { + const resolvedConfig = { + ...(config || (typeof configOrTitle === 'object' ? configOrTitle : {})), + cancelButtonClass: 'btn--cancel', + confirmButtonClass: 'btn--confirm', + }; + + if (typeof configOrTitle === 'string') { + return await MessageBox.prompt(message, configOrTitle, resolvedConfig); + } + return await MessageBox.prompt(message, resolvedConfig); + } + + return { + alert, + confirm, + prompt, + message: Message, + }; +} diff --git a/packages/editor-ui/src/composables/useTelemetry.ts b/packages/editor-ui/src/composables/useTelemetry.ts new file mode 100644 index 0000000000000..856b15477b84e --- /dev/null +++ b/packages/editor-ui/src/composables/useTelemetry.ts @@ -0,0 +1,5 @@ +import { Telemetry, telemetry } from '@/plugins/telemetry'; + +export function useTelemetry(): Telemetry { + return telemetry; +} diff --git a/packages/editor-ui/src/composables/useToast.ts b/packages/editor-ui/src/composables/useToast.ts new file mode 100644 index 0000000000000..97fff04d7030a --- /dev/null +++ b/packages/editor-ui/src/composables/useToast.ts @@ -0,0 +1,142 @@ +import { Notification } from 'element-ui'; +import type { ElNotificationComponent, ElNotificationOptions } from 'element-ui/types/notification'; +import type { MessageType } from 'element-ui/types/message'; +import { sanitizeHtml } from '@/utils'; +import { useTelemetry } from '@/composables/useTelemetry'; +import { useWorkflowsStore } from '@/stores'; +import { useI18n } from './useI18n'; +import { useExternalHooks } from './useExternalHooks'; + +const messageDefaults: Partial> = { + dangerouslyUseHTMLString: true, + position: 'bottom-right', +}; + +const stickyNotificationQueue: ElNotificationComponent[] = []; + +export function useToast() { + const telemetry = useTelemetry(); + const workflowsStore = useWorkflowsStore(); + const externalHooks = useExternalHooks(); + const i18n = useI18n(); + + function showMessage( + messageData: Omit & { message?: string }, + track = true, + ) { + messageData = { ...messageDefaults, ...messageData }; + messageData.message = messageData.message + ? sanitizeHtml(messageData.message) + : messageData.message; + + const notification = Notification(messageData as ElNotificationOptions); + + if (messageData.duration === 0) { + stickyNotificationQueue.push(notification); + } + + if (messageData.type === 'error' && track) { + telemetry.track('Instance FE emitted error', { + error_title: messageData.title, + error_message: messageData.message, + workflow_id: workflowsStore.workflowId, + }); + } + + return notification; + } + + function showToast(config: { + title: string; + message: string; + onClick?: () => void; + onClose?: () => void; + duration?: number; + customClass?: string; + closeOnClick?: boolean; + type?: MessageType; + }) { + // eslint-disable-next-line prefer-const + let notification: ElNotificationComponent; + if (config.closeOnClick) { + const cb = config.onClick; + config.onClick = () => { + if (notification) { + notification.close(); + } + + if (cb) { + cb(); + } + }; + } + + notification = showMessage({ + title: config.title, + message: config.message, + onClick: config.onClick, + onClose: config.onClose, + duration: config.duration, + customClass: config.customClass, + type: config.type, + }); + + return notification; + } + + function collapsableDetails({ description, node }: Error) { + if (!description) return ''; + + const errorDescription = + description.length > 500 ? `${description.slice(0, 500)}...` : description; + + return ` +
+
+
+ + ${i18n.baseText('showMessage.showDetails')} + +

${node.name}: ${errorDescription}

+
+ `; + } + + function showError(e: Error | unknown, title: string, message?: string) { + const error = e as Error; + const messageLine = message ? `${message}
` : ''; + showMessage( + { + title, + message: ` + ${messageLine} + ${error.message} + ${collapsableDetails(error)}`, + type: 'error', + duration: 0, + }, + false, + ); + + externalHooks.run('showMessage.showError', { + title, + message, + errorMessage: error.message, + }); + + telemetry.track('Instance FE emitted error', { + error_title: title, + error_description: message, + error_message: error.message, + workflow_id: workflowsStore.workflowId, + }); + } + + return { + showMessage, + showToast, + showError, + }; +} diff --git a/packages/editor-ui/src/composables/useUpgradeLink.ts b/packages/editor-ui/src/composables/useUpgradeLink.ts new file mode 100644 index 0000000000000..89cbaeeb4aca5 --- /dev/null +++ b/packages/editor-ui/src/composables/useUpgradeLink.ts @@ -0,0 +1,25 @@ +import { BaseTextKey } from '@/plugins/i18n'; +import { useUIStore, useUsageStore } from '@/stores'; +import { useI18n } from '@/composables'; +import { computed } from 'vue'; + +export function useUpgradeLink(queryParams = { default: '', desktop: '' }) { + const uiStore = useUIStore(); + const usageStore = useUsageStore(); + const i18n = useI18n(); + + const upgradeLinkUrl = computed(() => { + const linkUrlTranslationKey = uiStore.contextBasedTranslationKeys.upgradeLinkUrl as BaseTextKey; + let url = i18n.baseText(linkUrlTranslationKey); + + if (linkUrlTranslationKey.endsWith('.upgradeLinkUrl')) { + url = `${usageStore.viewPlansUrl}${queryParams.default}`; + } else if (linkUrlTranslationKey.endsWith('.desktop')) { + url = `${url}${queryParams.desktop}`; + } + + return url; + }); + + return { upgradeLinkUrl }; +} diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 1dd90ccbada3b..b74bc5bbf87b2 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -371,6 +371,7 @@ export enum VIEWS { TEMPLATE = 'TemplatesWorkflowView', TEMPLATES = 'TemplatesSearchView', CREDENTIALS = 'CredentialsView', + VARIABLES = 'VariablesView', NEW_WORKFLOW = 'NodeViewNew', WORKFLOW = 'NodeViewExisting', DEMO = 'WorkflowDemo', @@ -434,6 +435,7 @@ export const MAPPING_PARAMS = [ '$resumeWebhookUrl', '$runIndex', '$today', + '$vars', '$workflow', ]; @@ -457,6 +459,7 @@ export enum EnterpriseEditionFeature { Sharing = 'sharing', Ldap = 'ldap', LogStreaming = 'logStreaming', + Variables = 'variables', Saml = 'saml', } export const MAIN_NODE_PANEL_WIDTH = 360; diff --git a/packages/editor-ui/src/mixins/workflowHelpers.ts b/packages/editor-ui/src/mixins/workflowHelpers.ts index e3bdecac32d5c..5fc69ba9bf46b 100644 --- a/packages/editor-ui/src/mixins/workflowHelpers.ts +++ b/packages/editor-ui/src/mixins/workflowHelpers.ts @@ -65,6 +65,7 @@ import { useWorkflowsEEStore } from '@/stores/workflows.ee'; import { useUsersStore } from '@/stores/users'; import { getWorkflowPermissions, IPermissions } from '@/permissions'; import { ICredentialsResponse } from '@/Interface'; +import { useEnvironmentsStore } from '@/stores'; let cachedWorkflowKey: string | null = ''; let cachedWorkflow: Workflow | null = null; @@ -177,6 +178,7 @@ export function resolveParameter( mode: 'test', resumeUrl: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, }, + $vars: useEnvironmentsStore().variablesAsObject, // deprecated $executionId: PLACEHOLDER_FILLED_AT_EXECUTION_TIME, diff --git a/packages/editor-ui/src/permissions.ts b/packages/editor-ui/src/permissions.ts index 82ba1e0bb83b8..a2300d6399b7f 100644 --- a/packages/editor-ui/src/permissions.ts +++ b/packages/editor-ui/src/permissions.ts @@ -4,7 +4,13 @@ * @usage getCredentialPermissions(user, credential).isOwner; */ -import { IUser, ICredentialsResponse, IRootState, IWorkflowDb } from '@/Interface'; +import { + IUser, + ICredentialsResponse, + IRootState, + IWorkflowDb, + EnvironmentVariable, +} from '@/Interface'; import { EnterpriseEditionFeature, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { useSettingsStore } from './stores/settings'; @@ -130,3 +136,23 @@ export const getWorkflowPermissions = (user: IUser | null, workflow: IWorkflowDb return parsePermissionsTable(user, table); }; + +export const getVariablesPermissions = (user: IUser | null) => { + const table: IPermissionsTable = [ + { + name: 'create', + test: [UserRole.InstanceOwner], + }, + { + name: 'edit', + test: [UserRole.InstanceOwner], + }, + { + name: 'delete', + test: [UserRole.InstanceOwner], + }, + { name: 'use', test: () => true }, + ]; + + return parsePermissionsTable(user, table); +}; diff --git a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts index 1362354167ff8..060a9604fc584 100644 --- a/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts +++ b/packages/editor-ui/src/plugins/codemirror/completions/datatype.completions.ts @@ -20,6 +20,7 @@ import { NativeDoc } from 'n8n-workflow/src/Extensions/Extensions'; import { isFunctionOption } from './typeGuards'; import { luxonInstanceDocs } from './nativesAutocompleteDocs/luxon.instance.docs'; import { luxonStaticDocs } from './nativesAutocompleteDocs/luxon.static.docs'; +import { useEnvironmentsStore } from '@/stores'; /** * Resolution-based completions offered according to datatype. @@ -31,7 +32,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul if (word.from === word.to && !context.explicit) return null; - const [base, tail] = splitBaseTail(word.text); + // eslint-disable-next-line prefer-const + let [base, tail] = splitBaseTail(word.text); let options: Completion[] = []; @@ -39,6 +41,8 @@ export function datatypeCompletions(context: CompletionContext): CompletionResul options = luxonStaticOptions().map(stripExcessParens(context)); } else if (base === 'Object') { options = objectGlobalOptions().map(stripExcessParens(context)); + } else if (base === '$vars') { + options = variablesOptions(); } else { let resolved: Resolved; @@ -331,6 +335,22 @@ function ensureKeyCanBeResolved(obj: IDataObject, key: string) { } } +export const variablesOptions = () => { + const environmentsStore = useEnvironmentsStore(); + const variables = environmentsStore.variables; + + return variables.map((variable) => + createCompletionOption('Object', variable.key, 'keyword', { + doc: { + name: variable.key, + returnType: 'string', + description: i18n.baseText('codeNodeEditor.completer.$vars.varName'), + docURL: 'https://docs.n8n.io/environments/variables/', + }, + }), + ); +}; + /** * Methods and fields defined on a Luxon `DateTime` class instance. */ diff --git a/packages/editor-ui/src/plugins/components.ts b/packages/editor-ui/src/plugins/components.ts index 567a26d1ccfb1..075742d8451d4 100644 --- a/packages/editor-ui/src/plugins/components.ts +++ b/packages/editor-ui/src/plugins/components.ts @@ -7,10 +7,10 @@ import VueAgile from 'vue-agile'; import 'regenerator-runtime/runtime'; import ElementUI from 'element-ui'; -import { Loading, MessageBox, Message, Notification } from 'element-ui'; +import { Loading, MessageBox, Notification } from 'element-ui'; import { designSystemComponents } from 'n8n-design-system'; -import { ElMessageBoxOptions } from 'element-ui/types/message-box'; import EnterpriseEdition from '@/components/EnterpriseEdition.ee.vue'; +import { useMessage } from '@/composables/useMessage'; Vue.use(Fragment.Plugin); Vue.use(VueAgile); @@ -25,62 +25,11 @@ Vue.use(Loading.directive); Vue.prototype.$loading = Loading.service; Vue.prototype.$msgbox = MessageBox; -Vue.prototype.$alert = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - }; +const messageService = useMessage(); - if (typeof configOrTitle === 'string') { - return await MessageBox.alert(message, configOrTitle, temp); - } - return await MessageBox.alert(message, temp); -}; - -Vue.prototype.$confirm = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - distinguishCancelAndClose: true, - showClose: config.showClose || false, - closeOnClickModal: false, - }; - - if (typeof configOrTitle === 'string') { - return await MessageBox.confirm(message, configOrTitle, temp); - } - return await MessageBox.confirm(message, temp); -}; - -Vue.prototype.$prompt = async ( - message: string, - configOrTitle: string | ElMessageBoxOptions | undefined, - config: ElMessageBoxOptions | undefined, -) => { - let temp = config || (typeof configOrTitle === 'object' ? configOrTitle : {}); - temp = { - ...temp, - cancelButtonClass: 'btn--cancel', - confirmButtonClass: 'btn--confirm', - }; - - if (typeof configOrTitle === 'string') { - return await MessageBox.prompt(message, configOrTitle, temp); - } - return await MessageBox.prompt(message, temp); -}; +Vue.prototype.$alert = messageService.alert; +Vue.prototype.$confirm = messageService.confirm; +Vue.prototype.$prompt = messageService.prompt; +Vue.prototype.$message = messageService.message; Vue.prototype.$notify = Notification; -Vue.prototype.$message = Message; diff --git a/packages/editor-ui/src/plugins/i18n/index.ts b/packages/editor-ui/src/plugins/i18n/index.ts index aa1f434342b72..bb3daaf5f70fa 100644 --- a/packages/editor-ui/src/plugins/i18n/index.ts +++ b/packages/editor-ui/src/plugins/i18n/index.ts @@ -341,6 +341,7 @@ export class I18nClass { $min: this.baseText('codeNodeEditor.completer.$min'), $runIndex: this.baseText('codeNodeEditor.completer.$runIndex'), $today: this.baseText('codeNodeEditor.completer.$today'), + $vars: this.baseText('codeNodeEditor.completer.$vars'), $workflow: this.baseText('codeNodeEditor.completer.$workflow'), }; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 62bce2e86a450..781d56acc504c 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -142,6 +142,8 @@ "codeNodeEditor.completer.$prevNode.runIndex": "The run of the node providing input data to the current one", "codeNodeEditor.completer.$runIndex": "The index of the current run of this node", "codeNodeEditor.completer.$today": "A timestamp representing the current day (at midnight, as a Luxon object)", + "codeNodeEditor.completer.$vars": "The variables defined in your instance", + "codeNodeEditor.completer.$vars.varName": "Variable set on this n8n instance. All variables evaluate to strings.", "codeNodeEditor.completer.$workflow": "Information about the workflow", "codeNodeEditor.completer.$workflow.active": "Whether the workflow is active or not (boolean)", "codeNodeEditor.completer.$workflow.id": "The ID of the workflow", @@ -595,6 +597,7 @@ "mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?", "mainSidebar.confirmMessage.workflowDelete.message": "Are you sure that you want to delete '{workflowName}'?", "mainSidebar.credentials": "Credentials", + "mainSidebar.variables": "Variables", "mainSidebar.help": "Help", "mainSidebar.helpMenuItems.course": "Course", "mainSidebar.helpMenuItems.documentation": "Documentation", @@ -1601,6 +1604,39 @@ "importParameter.showError.invalidProtocol1.title": "Use the {node} node", "importParameter.showError.invalidProtocol2.title": "Invalid Protocol", "importParameter.showError.invalidProtocol.message": "The HTTP node doesnโ€™t support {protocol} requests", + "variables.heading": "Variables", + "variables.add": "Add Variable", + "variables.add.unavailable": "Upgrade plan to keep using variables", + "variables.add.onlyOwnerCanCreate": "Only owner can create variables", + "variables.empty.heading": "{name}, let's set up a variable", + "variables.empty.heading.userNotSetup": "Set up a variable", + "variables.empty.description": "Variables can be used to store data that can be referenced easily across multiple workflows.", + "variables.empty.button": "Add first variable", + "variables.noResults": "No variables found", + "variables.sort.nameAsc": "Sort by name (A-Z)", + "variables.sort.nameDesc": "Sort by name (Z-A)", + "variables.table.key": "Key", + "variables.table.value": "Value", + "variables.table.usage": "Usage Syntax", + "variables.editing.key.placeholder": "Enter a name", + "variables.editing.value.placeholder": "Enter a value", + "variables.editing.key.error.startsWithLetter": "This field may only start with a letter", + "variables.editing.key.error.jsonKey": "This field may contain only letters, numbers, and underscores", + "variables.row.button.save": "Save", + "variables.row.button.cancel": "Cancel", + "variables.row.button.edit": "Edit", + "variables.row.button.edit.onlyOwnerCanSave": "Only owner can edit variables", + "variables.row.button.delete": "Delete", + "variables.row.button.delete.onlyOwnerCanDelete": "Only owner can delete variables", + "variables.row.usage.copiedToClipboard": "Copied to clipboard", + "variables.row.usage.copyToClipboard": "Copy to clipboard", + "variables.search.placeholder": "Search variables...", + "variables.errors.save": "Error while saving variable", + "variables.errors.delete": "Error while deleting variable", + "variables.modals.deleteConfirm.title": "Delete variable", + "variables.modals.deleteConfirm.message": "Are you sure you want to delete the variable \"{name}\"? This cannot be undone.", + "variables.modals.deleteConfirm.confirmButton": "Delete", + "variables.modals.deleteConfirm.cancelButton": "Cancel", "contextual.credentials.sharing.unavailable.title": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.cloud": "Upgrade to collaborate", "contextual.credentials.sharing.unavailable.title.desktop": "Upgrade to n8n Cloud to collaborate", @@ -1625,6 +1661,14 @@ "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.cloud": "Available on Power 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 $vars (e.g. $vars.myVariable). Variables are immutable and cannot be modified within your workflows.
Learn more in the docs.", + "contextual.variables.unavailable.button": "View plans", + "contextual.variables.unavailable.button.cloud": "Upgrade now", + "contextual.variables.unavailable.button.desktop": "View plans", + "contextual.users.settings.unavailable.title": "Upgrade to add users", "contextual.users.settings.unavailable.title.cloud": "Upgrade to add users", "contextual.users.settings.unavailable.title.desktop": "Upgrade to add users", diff --git a/packages/editor-ui/src/plugins/icons/custom.ts b/packages/editor-ui/src/plugins/icons/custom.ts new file mode 100644 index 0000000000000..9817abf254e84 --- /dev/null +++ b/packages/editor-ui/src/plugins/icons/custom.ts @@ -0,0 +1,13 @@ +import type { IconDefinition, IconName, IconPrefix } from '@fortawesome/fontawesome-svg-core'; + +export const faVariable: IconDefinition = { + prefix: 'fas' as IconPrefix, + iconName: 'variable' as IconName, + icon: [ + 52, + 52, + [], + 'e001', + 'M42.6,17.8c2.4,0,7.2-2,7.2-8.4c0-6.4-4.6-6.8-6.1-6.8c-2.8,0-5.6,2-8.1,6.3c-2.5,4.4-5.3,9.1-5.3,9.1 l-0.1,0c-0.6-3.1-1.1-5.6-1.3-6.7c-0.5-2.7-3.6-8.4-9.9-8.4c-6.4,0-12.2,3.7-12.2,3.7l0,0C5.8,7.3,5.1,8.5,5.1,9.9 c0,2.1,1.7,3.9,3.9,3.9c0.6,0,1.2-0.2,1.7-0.4l0,0c0,0,4.8-2.7,5.9,0c0.3,0.8,0.6,1.7,0.9,2.7c1.2,4.2,2.4,9.1,3.3,13.5l-4.2,6 c0,0-4.7-1.7-7.1-1.7s-7.2,2-7.2,8.4s4.6,6.8,6.1,6.8c2.8,0,5.6-2,8.1-6.3c2.5-4.4,5.3-9.1,5.3-9.1c0.8,4,1.5,7.1,1.9,8.5 c1.6,4.5,5.3,7.2,10.1,7.2c0,0,5,0,10.9-3.3c1.4-0.6,2.4-2,2.4-3.6c0-2.1-1.7-3.9-3.9-3.9c-0.6,0-1.2,0.2-1.7,0.4l0,0 c0,0-4.2,2.4-5.6,0.5c-1-2-1.9-4.6-2.6-7.8c-0.6-2.8-1.3-6.2-2-9.5l4.3-6.2C35.5,16.1,40.2,17.8,42.6,17.8z', + ], +}; diff --git a/packages/editor-ui/src/plugins/icons.ts b/packages/editor-ui/src/plugins/icons/index.ts similarity index 93% rename from packages/editor-ui/src/plugins/icons.ts rename to packages/editor-ui/src/plugins/icons/index.ts index 7656b7aef4e1f..90257a28ce316 100644 --- a/packages/editor-ui/src/plugins/icons.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -1,6 +1,7 @@ import Vue from 'vue'; -import { IconDefinition, library } from '@fortawesome/fontawesome-svg-core'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; import { faAngleDoubleLeft, faAngleDown, @@ -128,12 +129,12 @@ import { faStickyNote as faSolidStickyNote, faUserLock, } from '@fortawesome/free-solid-svg-icons'; +import { faVariable } from './custom'; import { faStickyNote } from '@fortawesome/free-regular-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function addIcon(icon: any) { - library.add(icon as IconDefinition); +function addIcon(icon: IconDefinition) { + library.add(icon); } addIcon(faAngleDoubleLeft); @@ -239,7 +240,7 @@ addIcon(faSignOutAlt); addIcon(faSlidersH); addIcon(faSpinner); addIcon(faSolidStickyNote); -addIcon(faStickyNote); +addIcon(faStickyNote as IconDefinition); addIcon(faStop); addIcon(faSun); addIcon(faSync); @@ -259,6 +260,7 @@ addIcon(faUser); addIcon(faUserCircle); addIcon(faUserFriends); addIcon(faUsers); +addIcon(faVariable); addIcon(faVideo); addIcon(faTree); addIcon(faUserLock); diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 7a548d3768381..1dd3e2317060c 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -28,6 +28,7 @@ import TemplatesSearchView from '@/views/TemplatesSearchView.vue'; import CredentialsView from '@/views/CredentialsView.vue'; import ExecutionsView from '@/views/ExecutionsView.vue'; import WorkflowsView from '@/views/WorkflowsView.vue'; +import VariablesView from '@/views/VariablesView.vue'; import { IPermissions } from './Interface'; import { LOGIN_STATUS, ROLE } from '@/utils'; import { RouteConfigSingleView } from 'vue-router/types/router'; @@ -178,6 +179,21 @@ export const routes = [ }, }, }, + { + path: '/variables', + name: VIEWS.VARIABLES, + components: { + default: VariablesView, + sidebar: MainSidebar, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + }, + }, + }, { path: '/executions', name: VIEWS.EXECUTIONS, diff --git a/packages/editor-ui/src/stores/__tests__/environments.spec.ts b/packages/editor-ui/src/stores/__tests__/environments.spec.ts new file mode 100644 index 0000000000000..3df3f6b28d165 --- /dev/null +++ b/packages/editor-ui/src/stores/__tests__/environments.spec.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { useEnvironmentsStore } from '@/stores/environments.ee'; +import { EnvironmentVariable } from '@/Interface'; + +describe('store', () => { + let server: ReturnType; + const seedRecordsCount = 3; + + beforeAll(() => { + server = setupServer(); + server.createList('variable', seedRecordsCount); + }); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + afterAll(() => { + server.shutdown(); + }); + + describe('variables', () => { + describe('fetchAllVariables()', () => { + it('should fetch all credentials', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + expect(environmentsStore.variables).toHaveLength(seedRecordsCount); + }); + }); + + describe('createVariable()', () => { + it('should store a new variable', async () => { + const variable: Omit = { + key: 'ENV_VAR', + value: 'SECRET', + }; + const environmentsStore = useEnvironmentsStore(); + + await environmentsStore.fetchAllVariables(); + const recordsCount = environmentsStore.variables.length; + + expect(environmentsStore.variables).toHaveLength(recordsCount); + + await environmentsStore.createVariable(variable); + + expect(environmentsStore.variables).toHaveLength(recordsCount + 1); + expect(environmentsStore.variables[0]).toMatchObject(variable); + }); + }); + + describe('updateVariable()', () => { + it('should update an existing variable', async () => { + const updateValue: Partial = { + key: 'ENV_VAR', + value: 'SECRET', + }; + + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + await environmentsStore.updateVariable({ + ...environmentsStore.variables[0], + ...updateValue, + }); + + expect(environmentsStore.variables[0]).toMatchObject(updateValue); + }); + }); + + describe('deleteVariable()', () => { + it('should delete an existing variable', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + const recordsCount = environmentsStore.variables.length; + + await environmentsStore.deleteVariable(environmentsStore.variables[0]); + + expect(environmentsStore.variables).toHaveLength(recordsCount - 1); + }); + }); + + describe('variablesAsObject', () => { + it('should return variables as a key-value object', async () => { + const environmentsStore = useEnvironmentsStore(); + await environmentsStore.fetchAllVariables(); + + expect(environmentsStore.variablesAsObject).toEqual( + environmentsStore.variables.reduce>((acc, variable) => { + acc[variable.key] = variable.value; + return acc; + }, {}), + ); + }); + }); + }); +}); diff --git a/packages/editor-ui/src/stores/usage.test.ts b/packages/editor-ui/src/stores/__tests__/usage.test.ts similarity index 100% rename from packages/editor-ui/src/stores/usage.test.ts rename to packages/editor-ui/src/stores/__tests__/usage.test.ts diff --git a/packages/editor-ui/src/stores/environments.ee.ts b/packages/editor-ui/src/stores/environments.ee.ts new file mode 100644 index 0000000000000..ac28effe9a737 --- /dev/null +++ b/packages/editor-ui/src/stores/environments.ee.ts @@ -0,0 +1,65 @@ +import { defineStore } from 'pinia'; +import { useSettingsStore } from '@/stores/settings'; +import { computed, ref } from 'vue'; +import { EnvironmentVariable } from '@/Interface'; +import * as environmentsApi from '@/api/environments.ee'; +import { useRootStore } from '@/stores/n8nRootStore'; +import { createVariable } from '@/api/environments.ee'; + +export const useEnvironmentsStore = defineStore('environments', () => { + const rootStore = useRootStore(); + + const variables = ref([]); + + async function fetchAllVariables() { + const data = await environmentsApi.getVariables(rootStore.getRestApiContext); + + variables.value = data; + + return data; + } + + async function createVariable(variable: Omit) { + const data = await environmentsApi.createVariable(rootStore.getRestApiContext, variable); + + variables.value.unshift(data); + + return data; + } + + async function updateVariable(variable: EnvironmentVariable) { + const data = await environmentsApi.updateVariable(rootStore.getRestApiContext, variable); + + variables.value = variables.value.map((v) => (v.id === data.id ? data : v)); + + return data; + } + + async function deleteVariable(variable: EnvironmentVariable) { + const data = await environmentsApi.deleteVariable(rootStore.getRestApiContext, { + id: variable.id, + }); + + variables.value = variables.value.filter((v) => v.id !== variable.id); + + return data; + } + + const variablesAsObject = computed(() => + variables.value.reduce>((acc, variable) => { + acc[variable.key] = variable.value; + return acc; + }, {}), + ); + + return { + variables, + variablesAsObject, + fetchAllVariables, + createVariable, + updateVariable, + deleteVariable, + }; +}); + +export default useEnvironmentsStore; diff --git a/packages/editor-ui/src/stores/index.ts b/packages/editor-ui/src/stores/index.ts new file mode 100644 index 0000000000000..927ff1ae2961f --- /dev/null +++ b/packages/editor-ui/src/stores/index.ts @@ -0,0 +1,23 @@ +export * from './canvas'; +export * from './communityNodes'; +export * from './credentials'; +export * from './environments.ee'; +export * from './history'; +export * from './logStreamingStore'; +export * from './n8nRootStore'; +export * from './ndv'; +export * from './nodeCreator'; +export * from './nodeTypes'; +export * from './posthog'; +export * from './segment'; +export * from './settings'; +export * from './tags'; +export * from './telemetry'; +export * from './templates'; +export * from './ui'; +export * from './usage'; +export * from './users'; +export * from './versions'; +export * from './webhooks'; +export * from './workflows.ee'; +export * from './workflows'; diff --git a/packages/editor-ui/src/stores/ui.ts b/packages/editor-ui/src/stores/ui.ts index 666dc7af5bad2..f87a3e3744c95 100644 --- a/packages/editor-ui/src/stores/ui.ts +++ b/packages/editor-ui/src/stores/ui.ts @@ -215,6 +215,14 @@ export const useUIStore = defineStore(STORES.UI, { }, }, }, + variables: { + unavailable: { + title: `contextual.variables.unavailable.title${contextKey}`, + description: 'contextual.variables.unavailable.description', + action: `contextual.variables.unavailable.action${contextKey}`, + button: `contextual.variables.unavailable.button${contextKey}`, + }, + }, users: { settings: { unavailable: { diff --git a/packages/editor-ui/src/styles/autocomplete-theme.scss b/packages/editor-ui/src/styles/autocomplete-theme.scss index 8b65338ac4f9d..f67c42975936e 100644 --- a/packages/editor-ui/src/styles/autocomplete-theme.scss +++ b/packages/editor-ui/src/styles/autocomplete-theme.scss @@ -16,6 +16,12 @@ .cm-tooltip-autocomplete { background-color: var(--color-background-xlight) !important; + .cm-tooltip { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + li .cm-completionLabel { color: var(--color-success); } diff --git a/packages/editor-ui/src/views/CredentialsView.vue b/packages/editor-ui/src/views/CredentialsView.vue index 901ca18ebe872..7c037bce62da4 100644 --- a/packages/editor-ui/src/views/CredentialsView.vue +++ b/packages/editor-ui/src/views/CredentialsView.vue @@ -6,7 +6,7 @@ :initialize="initialize" :filters="filters" :additional-filters-handler="onFilter" - :item-size="77" + :type-props="{ itemSize: 77 }" @click:add="addCredential" @update:filters="filters = $event" > diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 41d840499a828..b7a8f66eb8906 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -241,7 +241,7 @@ import { TelemetryHelpers, Workflow, } from 'n8n-workflow'; -import { +import type { ICredentialsResponse, IExecutionResponse, IWorkflowDb, @@ -277,7 +277,8 @@ import { useCredentialsStore } from '@/stores/credentials'; import { useTagsStore } from '@/stores/tags'; import { useNodeCreatorStore } from '@/stores/nodeCreator'; import { useCanvasStore } from '@/stores/canvas'; -import useWorkflowsEEStore from '@/stores/workflows.ee'; +import { useWorkflowsEEStore } from '@/stores/workflows.ee'; +import { useEnvironmentsStore } from '@/stores'; import * as NodeViewUtils from '@/utils/nodeViewUtils'; import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils'; import { useHistoryStore } from '@/stores/history'; @@ -470,6 +471,7 @@ export default mixins( useWorkflowsStore, useUsersStore, useNodeCreatorStore, + useEnvironmentsStore, useWorkflowsEEStore, useHistoryStore, ), @@ -3627,6 +3629,9 @@ export default mixins( async loadCredentials(): Promise { await this.credentialsStore.fetchAllCredentials(); }, + async loadVariables(): Promise { + await this.environmentsStore.fetchAllVariables(); + }, async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise { const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes; @@ -3882,6 +3887,7 @@ export default mixins( this.loadCredentials(), this.loadCredentialTypes(), ]; + this.loadVariables(); if (this.nodeTypesStore.allNodeTypes.length === 0) { loadPromises.push(this.loadNodeTypes()); diff --git a/packages/editor-ui/src/views/VariablesView.vue b/packages/editor-ui/src/views/VariablesView.vue new file mode 100644 index 0000000000000..b150a92f1bcaf --- /dev/null +++ b/packages/editor-ui/src/views/VariablesView.vue @@ -0,0 +1,338 @@ + + + + + + + diff --git a/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts b/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts new file mode 100644 index 0000000000000..d69c98d40eb72 --- /dev/null +++ b/packages/editor-ui/src/views/__tests__/VariablesView.spec.ts @@ -0,0 +1,49 @@ +import { afterAll, beforeAll } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { setupServer } from '@/__tests__/server'; +import { render } from '@testing-library/vue'; +import VariablesView from '@/views/VariablesView.vue'; +import { useSettingsStore, useUsersStore } from '@/stores'; + +describe('store', () => { + let server: ReturnType; + + beforeAll(() => { + server = setupServer(); + }); + + beforeEach(async () => { + setActivePinia(createPinia()); + + await useSettingsStore().getSettings(); + await useUsersStore().fetchUsers(); + await useUsersStore().loginWithCookie(); + }); + + afterAll(() => { + server.shutdown(); + }); + + it('should render loading state', () => { + const wrapper = render(VariablesView); + + expect(wrapper.container.querySelectorAll('.n8n-loading')).toHaveLength(3); + }); + + it('should render empty state', async () => { + const wrapper = render(VariablesView); + + await wrapper.findByTestId('empty-resources-list'); + expect(wrapper.getByTestId('empty-resources-list')).toBeVisible(); + }); + + it('should render variable entries', async () => { + server.createList('variable', 3); + + const wrapper = render(VariablesView); + + await wrapper.findByTestId('resources-table'); + expect(wrapper.getByTestId('resources-table')).toBeVisible(); + expect(wrapper.container.querySelectorAll('tr')).toHaveLength(4); + }); +}); diff --git a/packages/nodes-base/test/nodes/Helpers.ts b/packages/nodes-base/test/nodes/Helpers.ts index b1d0603ad6c50..baa5871a31984 100644 --- a/packages/nodes-base/test/nodes/Helpers.ts +++ b/packages/nodes-base/test/nodes/Helpers.ts @@ -133,6 +133,7 @@ export function WorkflowExecuteAdditionalData( webhookWaitingBaseUrl: 'webhook-waiting', webhookTestBaseUrl: 'webhook-test', userId: '123', + variables: {}, }; } diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 5098b48e5cdeb..4e2e61d27252e 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -1692,6 +1692,7 @@ export interface IWorkflowExecuteAdditionalData { currentNodeParameters?: INodeParameters; executionTimeoutTimestamp?: number; userId: string; + variables: IDataObject; } export type WorkflowExecuteMode = diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d6b7edc43658..33840a85f4a37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -880,6 +880,9 @@ importers: codemirror-lang-n8n-expression: specifier: ^0.2.0 version: 0.2.0(@codemirror/state@6.1.4)(@codemirror/view@6.5.1)(@lezer/common@1.0.1) + copy-to-clipboard: + specifier: ^3.3.3 + version: 3.3.3 dateformat: specifier: ^3.0.3 version: 3.0.3 @@ -9831,6 +9834,12 @@ packages: is-plain-object: 5.0.0 dev: true + /copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + dependencies: + toggle-selection: 1.0.6 + dev: false + /copy-to@2.0.1: resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} dev: false @@ -20304,6 +20313,10 @@ packages: through2: 2.0.5 dev: true + /toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + dev: false + /toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'}