From fc6f9780d5bd1eeca42d87b429f46f205da0d7a0 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Thu, 1 Dec 2022 09:42:03 +0100 Subject: [PATCH 01/78] feat: Add license quotas endpoint --- packages/cli/src/Server.ts | 6 +++ .../1669823906993-AddTriggerCountColumn.ts | 47 +++++++++++++++++++ .../src/databases/migrations/sqlite/index.ts | 2 + packages/cli/src/license/LicenseController.ts | 30 ++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts create mode 100644 packages/cli/src/license/LicenseController.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 913d2ef69ca84..bc71b0ece54a6 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -157,6 +157,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { setupErrorMiddleware } from '@/ErrorReporting'; import { getLicense } from '@/License'; +import { licenseController } from './license/LicenseController'; require('body-parser-xml')(bodyParser); @@ -806,6 +807,11 @@ class App { // ---------------------------------------- this.app.use(`/${this.restEndpoint}/workflows`, workflowsController); + // ---------------------------------------- + // License + // ---------------------------------------- + this.app.use(`/${this.restEndpoint}/license`, licenseController); + // ---------------------------------------- // Tags // ---------------------------------------- diff --git a/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts b/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts new file mode 100644 index 0000000000000..151ea989f9f04 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; +import config from '@/config'; +import { INode } from 'n8n-workflow'; + +export class AddTriggerCountColumn1669823906993 implements MigrationInterface { + name = 'AddTriggerCountColumn1669823906993'; + + async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}workflow_entity\` ADD COLUMN "triggerCount" integer NOT NULL DEFAULT 0`, + ); + + const workflowsQuery = ` + SELECT id, nodes + FROM "${tablePrefix}workflow_entity" + `; + + const workflows: Array<{ id: number; nodes: string }> = await queryRunner.query(workflowsQuery); + + const updatePromises = workflows.map(async (workflow) => { + const nodes = JSON.parse(workflow.nodes); + const triggerCount = nodes.filter( + (node: INode) => + node.type.endsWith('trigger') && node.type !== 'n8n-nodes-base.manualTrigger', + ).length; + const query = `UPDATE "${tablePrefix}workflow_entity" SET triggerCount = ${triggerCount} WHERE id = ${workflow.id}`; + return queryRunner.query(query); + }); + + await Promise.all(updatePromises); + + logMigrationEnd(this.name); + } + + async down(queryRunner: QueryRunner): Promise { + const tablePrefix = config.getEnv('database.tablePrefix'); + + await queryRunner.query( + `ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN "triggerCount"`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index b4355a1dc02ee..fbfe4cfe1e2c0 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -20,6 +20,7 @@ import { CreateCredentialsUserRole1660062385367 } from './1660062385367-CreateCr import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWorkflowsUserRole'; import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable'; import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable'; +import { AddTriggerCountColumn1669823906993 } from './1669823906993-AddTriggerCountColumn'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -44,6 +45,7 @@ const sqliteMigrations = [ CreateWorkflowsEditorRole1663755770892, CreateCredentialUsageTable1665484192211, RemoveCredentialUsageTable1665754637024, + AddTriggerCountColumn1669823906993, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/license/LicenseController.ts b/packages/cli/src/license/LicenseController.ts new file mode 100644 index 0000000000000..1ce180ab45a4a --- /dev/null +++ b/packages/cli/src/license/LicenseController.ts @@ -0,0 +1,30 @@ +/* eslint-disable no-param-reassign */ + +import express from 'express'; +import { LoggerProxy } from 'n8n-workflow'; + +import { getLogger } from '@/Logger'; +import { ResponseHelper } from '..'; + +export const licenseController = express.Router(); + +/** + * Initialize Logger if needed + */ +licenseController.use((req, res, next) => { + try { + LoggerProxy.getInstance(); + } catch (error) { + LoggerProxy.init(getLogger()); + } + next(); +}); + +licenseController.get( + '/quota', + ResponseHelper.send(async () => { + return { + quota: 0, + }; + }), +); From efbfc8cf1423ac17456377ad4398b34cde516ee8 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Mon, 5 Dec 2022 09:53:02 +0100 Subject: [PATCH 02/78] feat: Add trigger count to workflow activation process --- packages/cli/src/ActiveWorkflowRunner.ts | 9 +++++++++ packages/cli/src/WebhookHelpers.ts | 2 +- .../src/databases/entities/WorkflowEntity.ts | 3 +++ .../1669823906993-AddTriggerCountColumn.ts | 20 +------------------ 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index b212dd5464ffa..dc7bc6784594a 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -854,6 +854,15 @@ export class ActiveWorkflowRunner { // If there were activation errors delete them delete this.activationErrors[workflowId]; } + + if (workflowInstance.id) { + const triggerCount = + workflowInstance.getTriggerNodes().length + + workflowInstance.getPollNodes().length + + WebhookHelpers.getWorkflowWebhooks(workflowInstance, additionalData, undefined, true) + .length; + await Db.collections.Workflow.update(workflowInstance.id, { triggerCount }); + } } catch (error) { // There was a problem activating the workflow diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index c2c74ae8a8f7c..e3e0312806da8 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -58,7 +58,7 @@ import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; export const WEBHOOK_METHODS = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; /** - * Returns all the webhooks which should be created for the give workflow + * Returns all the webhooks which should be created for the given workflow * */ export function getWorkflowWebhooks( diff --git a/packages/cli/src/databases/entities/WorkflowEntity.ts b/packages/cli/src/databases/entities/WorkflowEntity.ts index 06d47e40d1776..2f00ae56df439 100644 --- a/packages/cli/src/databases/entities/WorkflowEntity.ts +++ b/packages/cli/src/databases/entities/WorkflowEntity.ts @@ -116,6 +116,9 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb { this.hash = crypto.createHash('md5').update(state).digest('hex'); } + + @Column() + triggerCount: number; } /** diff --git a/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts b/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts index 151ea989f9f04..90d67e3226062 100644 --- a/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts +++ b/packages/cli/src/databases/migrations/sqlite/1669823906993-AddTriggerCountColumn.ts @@ -14,25 +14,7 @@ export class AddTriggerCountColumn1669823906993 implements MigrationInterface { await queryRunner.query( `ALTER TABLE \`${tablePrefix}workflow_entity\` ADD COLUMN "triggerCount" integer NOT NULL DEFAULT 0`, ); - - const workflowsQuery = ` - SELECT id, nodes - FROM "${tablePrefix}workflow_entity" - `; - - const workflows: Array<{ id: number; nodes: string }> = await queryRunner.query(workflowsQuery); - - const updatePromises = workflows.map(async (workflow) => { - const nodes = JSON.parse(workflow.nodes); - const triggerCount = nodes.filter( - (node: INode) => - node.type.endsWith('trigger') && node.type !== 'n8n-nodes-base.manualTrigger', - ).length; - const query = `UPDATE "${tablePrefix}workflow_entity" SET triggerCount = ${triggerCount} WHERE id = ${workflow.id}`; - return queryRunner.query(query); - }); - - await Promise.all(updatePromises); + // Table will be populated by n8n startup - see ActiveWorkflowRunner.ts logMigrationEnd(this.name); } From 7ab78f29441ba82ca0f0d9fe54c6f22273f54470 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Tue, 6 Dec 2022 12:53:26 +0100 Subject: [PATCH 03/78] refactor: Get quotas from db --- packages/cli/src/Server.ts | 2 +- packages/cli/src/license/License.service.ts | 14 ++++++++++++++ ...{LicenseController.ts => license.controller.ts} | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/license/License.service.ts rename packages/cli/src/license/{LicenseController.ts => license.controller.ts} (79%) diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index bc71b0ece54a6..6a2eaae5de79b 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -157,7 +157,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { toHttpNodeParameters } from '@/CurlConverterHelper'; import { setupErrorMiddleware } from '@/ErrorReporting'; import { getLicense } from '@/License'; -import { licenseController } from './license/LicenseController'; +import { licenseController } from './license/license.controller'; require('body-parser-xml')(bodyParser); diff --git a/packages/cli/src/license/License.service.ts b/packages/cli/src/license/License.service.ts new file mode 100644 index 0000000000000..b6d95c3213f4d --- /dev/null +++ b/packages/cli/src/license/License.service.ts @@ -0,0 +1,14 @@ +import { Db } from '..'; + +export class LicenseService { + static async getActiveTriggerCount(): Promise { + const qb = Db.collections.Workflow.createQueryBuilder('workflow') + .select('SUM(workflow.triggerCount)', 'triggerCount') + .where('workflow.active = :active', { active: true }); + const results: { triggerCount: number } | undefined = await qb.getRawOne(); + if (!results) { + throw new Error('Could not get active trigger count'); + } + return results.triggerCount; + } +} diff --git a/packages/cli/src/license/LicenseController.ts b/packages/cli/src/license/license.controller.ts similarity index 79% rename from packages/cli/src/license/LicenseController.ts rename to packages/cli/src/license/license.controller.ts index 1ce180ab45a4a..aa96ac381114f 100644 --- a/packages/cli/src/license/LicenseController.ts +++ b/packages/cli/src/license/license.controller.ts @@ -5,6 +5,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import { ResponseHelper } from '..'; +import { LicenseService } from './License.service'; export const licenseController = express.Router(); @@ -23,8 +24,9 @@ licenseController.use((req, res, next) => { licenseController.get( '/quota', ResponseHelper.send(async () => { + const triggerCount = await LicenseService.getActiveTriggerCount(); return { - quota: 0, + triggerCount, }; }), ); From 163fcba7a897bcee4e3074bd20ba0c3c847f6221 Mon Sep 17 00:00:00 2001 From: Omar Ajoue Date: Wed, 7 Dec 2022 11:54:09 +0100 Subject: [PATCH 04/78] feat: Add license information --- packages/cli/src/License.ts | 4 ++++ packages/cli/src/license/license.controller.ts | 15 +++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 0d20596b0a99d..b0c21ed82e0d1 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -108,6 +108,10 @@ export class License { isSharingEnabled() { return this.isFeatureEnabled(LICENSE_FEATURES.SHARING); } + + getFeatures() { + return this.manager?.getFeatures() ?? []; + } } let licenseInstance: License | undefined; diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index aa96ac381114f..f0dcffa99bf00 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -6,6 +6,7 @@ import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import { ResponseHelper } from '..'; import { LicenseService } from './License.service'; +import { getLicense, License } from '@/License'; export const licenseController = express.Router(); @@ -22,11 +23,21 @@ licenseController.use((req, res, next) => { }); licenseController.get( - '/quota', + '/', ResponseHelper.send(async () => { const triggerCount = await LicenseService.getActiveTriggerCount(); + + const features = getLicense().getFeatures(); + return { - triggerCount, + usage: { + executions: { + value: triggerCount, + limit: -1, + warningThreshold: 0.8, + }, + features, + }, }; }), ); From 8a90395cec7572ce224d92bc82bb54defbc1a1b0 Mon Sep 17 00:00:00 2001 From: freyamade Date: Wed, 7 Dec 2022 15:53:22 +0000 Subject: [PATCH 05/78] :sparkles: - finalised GET /license endpoint --- packages/cli/src/License.ts | 36 +++++++++++++++++-- .../cli/src/license/license.controller.ts | 11 +++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index b0c21ed82e0d1..fb596e7527038 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -1,4 +1,4 @@ -import { LicenseManager, TLicenseContainerStr } from '@n8n_io/license-sdk'; +import { LicenseManager, TEntitlement, TLicenseContainerStr } from '@n8n_io/license-sdk'; import { ILogger } from 'n8n-workflow'; import { getLogger } from './Logger'; import config from '@/config'; @@ -109,8 +109,38 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.SHARING); } - getFeatures() { - return this.manager?.getFeatures() ?? []; + getCurrentEntitlements() { + return this.manager?.getCurrentEntitlements() ?? []; + } + + getFeatureValue( + feature: string, + requireValidCert?: boolean, + ): undefined | boolean | number | string { + if (!this.manager) { + return undefined; + } + + return this.manager.getFeatureValue(feature, requireValidCert); + } + + /** + * Helper function to get the main plan for a license + */ + getMainPlan(): TEntitlement | undefined { + if (!this.manager) { + return undefined; + } + + const entitlements = this.manager.getCurrentEntitlements(); + if (!entitlements.length) { + return undefined; + } + + return entitlements.find( + (entitlement) => + (entitlement.productMetadata.terms as unknown as { isMainPlan: boolean }).isMainPlan, + ); } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index f0dcffa99bf00..de5a05deaa2dc 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -26,17 +26,20 @@ licenseController.get( '/', ResponseHelper.send(async () => { const triggerCount = await LicenseService.getActiveTriggerCount(); - - const features = getLicense().getFeatures(); + const license = getLicense(); + const mainPlan = license.getMainPlan(); return { usage: { executions: { value: triggerCount, - limit: -1, + limit: license.getFeatureValue('quota:activeWorkflows') ?? -1, warningThreshold: 0.8, }, - features, + }, + license: { + planId: mainPlan?.productId ?? '', + planName: license.getFeatureValue('planName') ?? 'Community', }, }; }), From a0ec6d6ab777bfea8d17cd1665277a87f2a8a179 Mon Sep 17 00:00:00 2001 From: freyamade Date: Wed, 7 Dec 2022 15:53:44 +0000 Subject: [PATCH 06/78] :hammer: - getActiveTriggerCount return 0 instead of null --- packages/cli/src/license/License.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/license/License.service.ts b/packages/cli/src/license/License.service.ts index b6d95c3213f4d..c007b91b2e95d 100644 --- a/packages/cli/src/license/License.service.ts +++ b/packages/cli/src/license/License.service.ts @@ -9,6 +9,6 @@ export class LicenseService { if (!results) { throw new Error('Could not get active trigger count'); } - return results.triggerCount; + return results.triggerCount ?? 0; } } From e904ae5b332512d1fa8761a594e58de6e5876742 Mon Sep 17 00:00:00 2001 From: freyamade Date: Wed, 7 Dec 2022 16:22:04 +0000 Subject: [PATCH 07/78] :bug: - ignore manualTrigger when counting active triggers --- packages/cli/src/ActiveWorkflowRunner.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index dc7bc6784594a..901348fb18b8e 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -32,6 +32,7 @@ import { WorkflowExecuteMode, LoggerProxy as Logger, ErrorReporterProxy as ErrorReporter, + INodeType, } from 'n8n-workflow'; import express from 'express'; @@ -800,6 +801,7 @@ export class ActiveWorkflowRunner { const canBeActivated = workflowInstance.checkIfWorkflowCanBeActivated([ 'n8n-nodes-base.start', + 'n8n-nodes-base.manualTrigger', ]); if (!canBeActivated) { Logger.error(`Unable to activate workflow "${workflowData.name}"`); @@ -856,8 +858,11 @@ export class ActiveWorkflowRunner { } if (workflowInstance.id) { + // Sum all triggers in the workflow, EXCLUDING the manual trigger + const triggerFilter = (nodeType: INodeType) => + !!nodeType.trigger && !nodeType.description.name.includes('manualTrigger'); const triggerCount = - workflowInstance.getTriggerNodes().length + + workflowInstance.queryNodes(triggerFilter).length + workflowInstance.getPollNodes().length + WebhookHelpers.getWorkflowWebhooks(workflowInstance, additionalData, undefined, true) .length; From 7b0bc25ecde79670404c131e901ad225bb48c5cd Mon Sep 17 00:00:00 2001 From: freyamade Date: Thu, 8 Dec 2022 11:18:24 +0000 Subject: [PATCH 08/78] :sparkles: - add activation endpoint --- packages/cli/src/License.ts | 13 +++- .../cli/src/license/license.controller.ts | 68 +++++++++++++++---- packages/cli/src/requests.d.ts | 8 +++ 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index fb596e7527038..1d2135bf6574d 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -65,7 +65,7 @@ export class License { } } - async activate(activationKey: string): Promise { + async activate(activationKey: string, throwError = false): Promise { if (!this.manager) { return; } @@ -79,6 +79,10 @@ export class License { } catch (e) { if (e instanceof Error) { this.logger.error('Could not activate license', e); + if (throwError) { + // Throw error up to the API + throw e; + } } } } @@ -124,6 +128,13 @@ export class License { return this.manager.getFeatureValue(feature, requireValidCert); } + getManagementJWT(): string { + if (!this.manager) { + return ''; + } + return this.manager.getManagementJwt(); + } + /** * Helper function to get the main plan for a license */ diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index de5a05deaa2dc..489fad359cb32 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -6,7 +6,9 @@ import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; import { ResponseHelper } from '..'; import { LicenseService } from './License.service'; -import { getLicense, License } from '@/License'; +import { getLicense } from '@/License'; +import { LicenseRequest } from '@/requests'; +import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service'; export const licenseController = express.Router(); @@ -22,25 +24,63 @@ licenseController.use((req, res, next) => { next(); }); +// Helper for getting the basic license data that we want to return +async function getLicenseData() { + const triggerCount = await LicenseService.getActiveTriggerCount(); + const license = getLicense(); + const mainPlan = license.getMainPlan(); + + return { + usage: { + executions: { + value: triggerCount, + limit: license.getFeatureValue('quota:activeWorkflows') ?? -1, + warningThreshold: 0.8, + }, + }, + license: { + planId: mainPlan?.productId ?? '', + planName: license.getFeatureValue('planName') ?? 'Community', + }, + }; +} + licenseController.get( '/', ResponseHelper.send(async () => { - const triggerCount = await LicenseService.getActiveTriggerCount(); + return getLicenseData(); + }), +); + +/** + * POST /license/activate + * Only usable by the instance owner, activates a license. + */ +licenseController.post( + '/activate', + ResponseHelper.send(async (req: LicenseRequest.Activate) => { + // First ensure that the requesting user is the instance owner + if (!isInstanceOwner(req.user)) { + LoggerProxy.info('Non-owner attempted to activate a license', { + userId: req.user.id, + }); + throw new ResponseHelper.NotFoundError('Only an instance owner may activate a license'); + } + + // Call the license manager activate function and tell it to throw an error const license = getLicense(); - const mainPlan = license.getMainPlan(); + try { + await license.activate(req.body.activationKey, true); + } catch (e) { + if (e instanceof Error) { + throw new ResponseHelper.BadRequestError(e.message); + } + } + // Return the read data, plus the management JWT return { - usage: { - executions: { - value: triggerCount, - limit: license.getFeatureValue('quota:activeWorkflows') ?? -1, - warningThreshold: 0.8, - }, - }, - license: { - planId: mainPlan?.productId ?? '', - planName: license.getFeatureValue('planName') ?? 'Community', - }, + managementToken: license.getManagementJWT(), + ...(await getLicenseData()), }; }), ); diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index 973b94b33bffc..e9a7578629ea9 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -340,3 +340,11 @@ export declare namespace NodeRequest { export declare namespace CurlHelper { type ToJson = AuthenticatedRequest<{}, {}, { curlCommand?: string }>; } + +// ---------------------------------- +// /license +// ---------------------------------- + +export declare namespace LicenseRequest { + type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>; +} From 53522e5d055ce2b7ff1c3ab51fa17e32f7e4a7b7 Mon Sep 17 00:00:00 2001 From: freyamade Date: Thu, 8 Dec 2022 11:30:37 +0000 Subject: [PATCH 09/78] :sparkles: - added renew endpoint --- packages/cli/src/License.ts | 5 ++- .../cli/src/license/license.controller.ts | 37 +++++++++++++++++++ packages/cli/src/requests.d.ts | 1 + 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 1d2135bf6574d..f384729c91df4 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -87,7 +87,7 @@ export class License { } } - async renew() { + async renew(throwError = false) { if (!this.manager) { return; } @@ -97,6 +97,9 @@ export class License { } catch (e) { if (e instanceof Error) { this.logger.error('Could not renew license', e); + if (throwError) { + throw e; + } } } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 489fad359cb32..1b7177f545448 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -45,6 +45,10 @@ async function getLicenseData() { }; } +/** + * GET /license + * Get the license data, usable by everyone + */ licenseController.get( '/', ResponseHelper.send(async () => { @@ -84,3 +88,36 @@ licenseController.post( }; }), ); + +/** + * POST /license/renew + * Only usable by instance owner, renews a license + */ +licenseController.post( + '/renew', + ResponseHelper.send(async (req: LicenseRequest.Renew) => { + // First ensure that the requesting user is the instance owner + if (!isInstanceOwner(req.user)) { + LoggerProxy.info('Non-owner attempted to renew a license', { + userId: req.user.id, + }); + throw new ResponseHelper.NotFoundError('Only an instance owner may renew a license'); + } + + // Call the license manager activate function and tell it to throw an error + const license = getLicense(); + try { + await license.renew(true); + } catch (e) { + if (e instanceof Error) { + throw new ResponseHelper.BadRequestError(e.message); + } + } + + // Return the read data, plus the management JWT + return { + managementToken: license.getManagementJWT(), + ...(await getLicenseData()), + }; + }), +); diff --git a/packages/cli/src/requests.d.ts b/packages/cli/src/requests.d.ts index e9a7578629ea9..681003b9432df 100644 --- a/packages/cli/src/requests.d.ts +++ b/packages/cli/src/requests.d.ts @@ -347,4 +347,5 @@ export declare namespace CurlHelper { export declare namespace LicenseRequest { type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>; + type Renew = AuthenticatedRequest<>; } From cb0e19036a7f6a65386e51df60094ba55933c729 Mon Sep 17 00:00:00 2001 From: freyamade Date: Thu, 8 Dec 2022 11:37:27 +0000 Subject: [PATCH 10/78] :hammer: - added return type interfaces --- packages/cli/src/Interfaces.ts | 22 +++++++++++++++++++ .../cli/src/license/license.controller.ts | 14 ++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index fa442b892e757..5da0a2d26d38d 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -730,3 +730,25 @@ export interface IExecutionTrackProperties extends ITelemetryTrackProperties { error_node_type?: string; is_manual: boolean; } + +// ---------------------------------- +// license +// ---------------------------------- + +export interface ILicenseReadResponse { + usage: { + executions: { + limit: number; + value: number; + warningThreshold: number; + }; + }; + license: { + planId: string; + planName: string; + }; +} + +export interface ILicensePostResponse extends ILicenseReadResponse { + managementToken: string; +} diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 1b7177f545448..e511c47ff8069 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -4,7 +4,7 @@ import express from 'express'; import { LoggerProxy } from 'n8n-workflow'; import { getLogger } from '@/Logger'; -import { ResponseHelper } from '..'; +import { ILicensePostResponse, ILicenseReadResponse, ResponseHelper } from '..'; import { LicenseService } from './License.service'; import { getLicense } from '@/License'; import { LicenseRequest } from '@/requests'; @@ -25,7 +25,7 @@ licenseController.use((req, res, next) => { }); // Helper for getting the basic license data that we want to return -async function getLicenseData() { +async function getLicenseData(): Promise { const triggerCount = await LicenseService.getActiveTriggerCount(); const license = getLicense(); const mainPlan = license.getMainPlan(); @@ -34,13 +34,13 @@ async function getLicenseData() { usage: { executions: { value: triggerCount, - limit: license.getFeatureValue('quota:activeWorkflows') ?? -1, + limit: (license.getFeatureValue('quota:activeWorkflows') ?? -1) as number, warningThreshold: 0.8, }, }, license: { planId: mainPlan?.productId ?? '', - planName: license.getFeatureValue('planName') ?? 'Community', + planName: (license.getFeatureValue('planName') ?? 'Community') as string, }, }; } @@ -51,7 +51,7 @@ async function getLicenseData() { */ licenseController.get( '/', - ResponseHelper.send(async () => { + ResponseHelper.send(async (): Promise => { return getLicenseData(); }), ); @@ -62,7 +62,7 @@ licenseController.get( */ licenseController.post( '/activate', - ResponseHelper.send(async (req: LicenseRequest.Activate) => { + ResponseHelper.send(async (req: LicenseRequest.Activate): Promise => { // First ensure that the requesting user is the instance owner if (!isInstanceOwner(req.user)) { LoggerProxy.info('Non-owner attempted to activate a license', { @@ -95,7 +95,7 @@ licenseController.post( */ licenseController.post( '/renew', - ResponseHelper.send(async (req: LicenseRequest.Renew) => { + ResponseHelper.send(async (req: LicenseRequest.Renew): Promise => { // First ensure that the requesting user is the instance owner if (!isInstanceOwner(req.user)) { LoggerProxy.info('Non-owner attempted to renew a license', { From 7afd7e1e9a4528f4dd735139860e900155dd936a Mon Sep 17 00:00:00 2001 From: freyamade Date: Thu, 8 Dec 2022 11:45:42 +0000 Subject: [PATCH 11/78] :hammer: - handle license errors where methods are called --- packages/cli/src/License.ts | 27 +++---------------- packages/cli/src/Server.ts | 6 ++++- .../cli/src/license/license.controller.ts | 4 +-- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index f384729c91df4..82914f4865f01 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -65,7 +65,7 @@ export class License { } } - async activate(activationKey: string, throwError = false): Promise { + async activate(activationKey: string): Promise { if (!this.manager) { return; } @@ -74,34 +74,15 @@ export class License { return; } - try { - await this.manager.activate(activationKey); - } catch (e) { - if (e instanceof Error) { - this.logger.error('Could not activate license', e); - if (throwError) { - // Throw error up to the API - throw e; - } - } - } + await this.manager.activate(activationKey); } - async renew(throwError = false) { + async renew() { if (!this.manager) { return; } - try { - await this.manager.renew(); - } catch (e) { - if (e instanceof Error) { - this.logger.error('Could not renew license', e); - if (throwError) { - throw e; - } - } - } + await this.manager.renew(); } isFeatureEnabled(feature: string): boolean { diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 6a2eaae5de79b..fc4fe8f4c274d 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -399,7 +399,11 @@ class App { const activationKey = config.getEnv('license.activationKey'); if (activationKey) { - await license.activate(activationKey); + try { + await license.activate(activationKey); + } catch (e) { + LoggerProxy.error('Could not activate license', e); + } } } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index e511c47ff8069..b51de89bc1576 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -74,7 +74,7 @@ licenseController.post( // Call the license manager activate function and tell it to throw an error const license = getLicense(); try { - await license.activate(req.body.activationKey, true); + await license.activate(req.body.activationKey); } catch (e) { if (e instanceof Error) { throw new ResponseHelper.BadRequestError(e.message); @@ -107,7 +107,7 @@ licenseController.post( // Call the license manager activate function and tell it to throw an error const license = getLicense(); try { - await license.renew(true); + await license.renew(); } catch (e) { if (e instanceof Error) { throw new ResponseHelper.BadRequestError(e.message); From c94ddaa4be963809c0649d50fcf0b7506ae9c645 Mon Sep 17 00:00:00 2001 From: freyamade Date: Thu, 8 Dec 2022 15:56:46 +0000 Subject: [PATCH 12/78] :hammer: - rename function to match name from lib --- packages/cli/src/License.ts | 2 +- packages/cli/src/license/license.controller.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 82914f4865f01..5a9b58c81f239 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -112,7 +112,7 @@ export class License { return this.manager.getFeatureValue(feature, requireValidCert); } - getManagementJWT(): string { + getManagementJwt(): string { if (!this.manager) { return ''; } diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index b51de89bc1576..cdfb3e960e18a 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -83,7 +83,7 @@ licenseController.post( // Return the read data, plus the management JWT return { - managementToken: license.getManagementJWT(), + managementToken: license.getManagementJwt(), ...(await getLicenseData()), }; }), @@ -116,7 +116,7 @@ licenseController.post( // Return the read data, plus the management JWT return { - managementToken: license.getManagementJWT(), + managementToken: license.getManagementJwt(), ...(await getLicenseData()), }; }), From bfe0eb5db75e0ec6981736f2ee06cb12be04a93a Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 9 Dec 2022 07:13:18 +0100 Subject: [PATCH 13/78] feat(editor): usage add plans buttons logic --- packages/editor-ui/src/Interface.ts | 1 + packages/editor-ui/src/api/usage.ts | 8 ++++++++ packages/editor-ui/src/plugins/i18n/locales/en.json | 1 + packages/editor-ui/src/stores/usage.ts | 12 +++++++++++- .../editor-ui/src/views/SettingsUsageAndPlan.vue | 7 +++++-- 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index c6d992af11e96..4e8771ee2a501 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1323,5 +1323,6 @@ export type UsageState = { planId: string, // community planName: string, // defaults to Community }, + managementToken?: string, } }; diff --git a/packages/editor-ui/src/api/usage.ts b/packages/editor-ui/src/api/usage.ts index 18ccfae5f48a7..d9923536c04c2 100644 --- a/packages/editor-ui/src/api/usage.ts +++ b/packages/editor-ui/src/api/usage.ts @@ -4,3 +4,11 @@ import { IRestApiContext, UsageState } from '@/Interface'; export const getLicense = (context: IRestApiContext): Promise<{data: UsageState['data']}> => { return makeRestApiRequest(context, 'GET', '/license'); }; + +export const activateLicense = (context: IRestApiContext, data: { activationKey: string }): Promise<{data: UsageState['data']}> => { + return makeRestApiRequest(context, 'POST', '/license/activate', data); +}; + +export const renewLicense = (context: IRestApiContext): Promise<{data: UsageState['data']}> => { + return makeRestApiRequest(context, 'POST', '/license/renew'); +}; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 07e738bb35200..99ddb3dbb8220 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1121,6 +1121,7 @@ "settings.usageAndPlan.activeWorkflows.hint": "If an active workflow contains multiple triggers, each will count as an active workflow. For example, 1 active workflow with 3 triggers will count as 3 active workflows.", "settings.usageAndPlan.button.activation": "Add activation key", "settings.usageAndPlan.button.plans": "View plans", + "settings.usageAndPlan.button.manage": "Manage plans", "showMessage.cancel": "@:_reusableBaseText.cancel", "showMessage.ok": "OK", "showMessage.showDetails": "Show Details", diff --git a/packages/editor-ui/src/stores/usage.ts b/packages/editor-ui/src/stores/usage.ts index 7ac340181a64d..267e4d73d0d4e 100644 --- a/packages/editor-ui/src/stores/usage.ts +++ b/packages/editor-ui/src/stores/usage.ts @@ -3,9 +3,15 @@ import { defineStore } from 'pinia'; import { UsageState } from '@/Interface'; import { getLicense } from '@/api/usage'; import { useRootStore } from '@/stores/n8nRootStore'; +import { useSettingsStore } from "@/stores/settings"; + +const DEFAULT_PLAN_ID = 'community'; +const DEFAULT_PLAN_NAME = 'Community'; +const SUBSCRIPTION_APP_URL = 'https://n8n-io.github.io/subscription-app'; export const useUsageStore = defineStore('usageAndPlan', () => { const rootStore = useRootStore(); + const settingsStore = useSettingsStore(); const state = reactive({ loading: true, @@ -44,9 +50,13 @@ export const useUsageStore = defineStore('usageAndPlan', () => { getData, setData, isLoading: computed(() => state.loading), - planName: computed(() => state.data.license.planName), + planName: computed(() => state.data.license.planName || DEFAULT_PLAN_NAME), executionLimit: computed(() => state.data.usage.executions.limit), executionCount: computed(() => state.data.usage.executions.value), isCloseToLimit: computed(() => state.data.usage.executions.limit < 0 ? false : state.data.usage.executions.value / state.data.usage.executions.limit >= state.data.usage.executions.warningThreshold), + instanceId: computed(() => settingsStore.settings.instanceId), + managementToken: computed(() => state.data.managementToken), + viewPlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}?instanceId=${settingsStore.settings.instanceId}`), + managePlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}/manage?token=${state.data.managementToken}`), }; }); diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue index 0c99fa6b7b7ef..df52e36051bfe 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue @@ -31,8 +31,11 @@ onMounted(() => { From c262566871a0edcd0e0614c3d3136193d0873076 Mon Sep 17 00:00:00 2001 From: freyamade Date: Fri, 9 Dec 2022 10:28:07 +0000 Subject: [PATCH 14/78] :rotating_light: - testing new License methods --- packages/cli/src/License.ts | 3 +- packages/cli/test/unit/License.test.ts | 42 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 5a9b58c81f239..72f8250bf6b3e 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -127,7 +127,8 @@ export class License { return undefined; } - const entitlements = this.manager.getCurrentEntitlements(); + const entitlements = this.getCurrentEntitlements(); + console.log(entitlements); if (!entitlements.length) { return undefined; } diff --git a/packages/cli/test/unit/License.test.ts b/packages/cli/test/unit/License.test.ts index 3a63c673e3a10..84b23ed07665c 100644 --- a/packages/cli/test/unit/License.test.ts +++ b/packages/cli/test/unit/License.test.ts @@ -10,6 +10,7 @@ const MOCK_INSTANCE_ID = 'instance-id'; const MOCK_N8N_VERSION = '0.27.0'; const MOCK_ACTIVATION_KEY = 'activation-key'; const MOCK_FEATURE_FLAG = 'feat:mock'; +const MOCK_MAIN_PLAN_ID = 1234; describe('License', () => { beforeAll(() => { @@ -74,4 +75,45 @@ describe('License', () => { expect(LicenseManager.prototype.hasFeatureEnabled).toHaveBeenCalledWith(MOCK_FEATURE_FLAG); }); + + test('check fetching entitlements', async () => { + await license.getCurrentEntitlements(); + + expect(LicenseManager.prototype.getCurrentEntitlements).toHaveBeenCalled(); + }); + + test('check fetching feature values', async () => { + await license.getFeatureValue(MOCK_FEATURE_FLAG, false); + + expect(LicenseManager.prototype.getFeatureValue).toHaveBeenCalledWith(MOCK_FEATURE_FLAG, false); + }); + + test('check management jwt', async () => { + await license.getManagementJwt(); + + expect(LicenseManager.prototype.getManagementJwt).toHaveBeenCalled(); + }); + + test('check main plan', async () => { + // mock entitlements response + License.prototype.getCurrentEntitlements = jest.fn().mockReturnValue([ + { + id: MOCK_MAIN_PLAN_ID, + productId: '', + productMetadata: { + terms: { + isMainPlan: true, + }, + }, + features: {}, + featureOverrides: {}, + validFrom: new Date(), + validTo: new Date(), + }, + ]); + jest.fn(license.getMainPlan).mockReset(); + + const mainPlan = license.getMainPlan(); + expect(mainPlan.id).toBe(MOCK_MAIN_PLAN_ID); + }); }); From f2123da19772258dd6d8aea1391e9e7cd9edc6a3 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 9 Dec 2022 13:30:36 +0100 Subject: [PATCH 15/78] feat(editor): usage add more business logic --- packages/editor-ui/package.json | 2 +- packages/editor-ui/src/api/usage.ts | 2 +- packages/editor-ui/src/stores/usage.ts | 72 +++++++++++++------ packages/editor-ui/src/stores/users.ts | 3 + packages/editor-ui/src/utils/userUtils.ts | 7 ++ .../src/views/SettingsUsageAndPlan.vue | 16 ++++- pnpm-lock.yaml | 2 +- 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 38f0dfb55cd71..d769da86c9809 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -70,7 +70,7 @@ "vue-i18n": "^8.26.7", "vue-json-pretty": "1.9.3", "vue-prism-editor": "^0.3.0", - "vue-router": "^3.0.6", + "vue-router": "^3.6.5", "vue-template-compiler": "^2.7", "vue-typed-mixins": "^0.2.0", "vue2-boring-avatars": "0.3.4", diff --git a/packages/editor-ui/src/api/usage.ts b/packages/editor-ui/src/api/usage.ts index d9923536c04c2..78e8f841702ee 100644 --- a/packages/editor-ui/src/api/usage.ts +++ b/packages/editor-ui/src/api/usage.ts @@ -5,7 +5,7 @@ export const getLicense = (context: IRestApiContext): Promise<{data: UsageState[ return makeRestApiRequest(context, 'GET', '/license'); }; -export const activateLicense = (context: IRestApiContext, data: { activationKey: string }): Promise<{data: UsageState['data']}> => { +export const activateLicenseKey = (context: IRestApiContext, data: { activationKey: string }): Promise<{data: UsageState['data']}> => { return makeRestApiRequest(context, 'POST', '/license/activate', data); }; diff --git a/packages/editor-ui/src/stores/usage.ts b/packages/editor-ui/src/stores/usage.ts index 267e4d73d0d4e..a732a5033c4d3 100644 --- a/packages/editor-ui/src/stores/usage.ts +++ b/packages/editor-ui/src/stores/usage.ts @@ -1,35 +1,40 @@ import { computed, reactive } from 'vue'; import { defineStore } from 'pinia'; +import { useRoute } from 'vue-router/composables'; import { UsageState } from '@/Interface'; -import { getLicense } from '@/api/usage'; +import {activateLicenseKey, getLicense, renewLicense} from '@/api/usage'; import { useRootStore } from '@/stores/n8nRootStore'; import { useSettingsStore } from "@/stores/settings"; +import { useUsersStore } from "@/stores/users"; +const SUBSCRIPTION_APP_URL = 'https://n8n-io.github.io/subscription-app'; const DEFAULT_PLAN_ID = 'community'; const DEFAULT_PLAN_NAME = 'Community'; -const SUBSCRIPTION_APP_URL = 'https://n8n-io.github.io/subscription-app'; +const DEFAULT_STATE: UsageState = { + loading: true, + error: null, + data: { + usage: { + executions: { + limit: -1, + value: 0, + warningThreshold: .8, + }, + }, + license: { + planId: DEFAULT_PLAN_ID, + planName: DEFAULT_PLAN_NAME, + }, + }, +}; -export const useUsageStore = defineStore('usageAndPlan', () => { +export const useUsageStore = defineStore('usage', () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); + const usersStore = useUsersStore(); + const route = useRoute(); - const state = reactive({ - loading: true, - error: null, - data: { - usage: { - executions: { - limit: -1, - value: 0, - warningThreshold: .8, - }, - }, - license: { - planId: 'community', - planName: 'Community', - }, - }, - }); + const state = reactive(DEFAULT_STATE); const setData = (data: UsageState['data']) => { state.data = data; @@ -46,9 +51,33 @@ export const useUsageStore = defineStore('usageAndPlan', () => { state.loading = false; }; + const activateLicense = async (activationKey: string) => { + state.loading = true; + try { + const { data } = await activateLicenseKey(rootStore.getRestApiContext, { activationKey }); + setData(data); + } catch (error) { + state.error = error; + } + state.loading = false; + }; + + const refreshLicenseManagementToken = async () => { + state.loading = true; + try { + const { data } = await renewLicense(rootStore.getRestApiContext); + setData(data); + } catch (error) { + state.error = error; + } + state.loading = false; + }; + return { getData, setData, + activateLicense, + refreshLicenseManagementToken, isLoading: computed(() => state.loading), planName: computed(() => state.data.license.planName || DEFAULT_PLAN_NAME), executionLimit: computed(() => state.data.usage.executions.limit), @@ -56,7 +85,8 @@ export const useUsageStore = defineStore('usageAndPlan', () => { isCloseToLimit: computed(() => state.data.usage.executions.limit < 0 ? false : state.data.usage.executions.value / state.data.usage.executions.limit >= state.data.usage.executions.warningThreshold), instanceId: computed(() => settingsStore.settings.instanceId), managementToken: computed(() => state.data.managementToken), - viewPlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}?instanceId=${settingsStore.settings.instanceId}`), + viewPlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}?instanceId=${settingsStore.settings.instanceId}&callback=${encodeURIComponent(`${window.location.origin}${route.fullPath}`)}`), managePlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}/manage?token=${state.data.managementToken}`), + canUserActivateLicense: computed(() => usersStore.canUserActivateLicense), }; }); diff --git a/packages/editor-ui/src/stores/users.ts b/packages/editor-ui/src/stores/users.ts index 504dd08baffbd..dca5c629c5079 100644 --- a/packages/editor-ui/src/stores/users.ts +++ b/packages/editor-ui/src/stores/users.ts @@ -32,6 +32,9 @@ export const useUsersStore = defineStore(STORES.USERS, { canUserDeleteTags(): boolean { return isAuthorized(PERMISSIONS.TAGS.CAN_DELETE_TAGS, this.currentUser); }, + canUserActivateLicense(): boolean { + return isAuthorized(PERMISSIONS.USAGE.CAN_ACTIVATE_LICENSE, this.currentUser); + }, canUserAccessSidebarUserInfo() { if (this.currentUser) { const currentUser: IUser = this.currentUser; diff --git a/packages/editor-ui/src/utils/userUtils.ts b/packages/editor-ui/src/utils/userUtils.ts index b9a31cec517c3..f9c42e662461c 100644 --- a/packages/editor-ui/src/utils/userUtils.ts +++ b/packages/editor-ui/src/utils/userUtils.ts @@ -47,6 +47,13 @@ export const PERMISSIONS: IUserPermissions = { }, }, }, + USAGE: { + CAN_ACTIVATE_LICENSE: { + allow: { + role: [ROLE.Owner], + }, + }, + }, }; /** diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue index df52e36051bfe..0b01a1f70122e 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue @@ -1,11 +1,21 @@ @@ -30,7 +40,7 @@ onMounted(() => { {{ $locale.baseText('settings.usageAndPlan.activeWorkflows.hint') }}
- {{ $locale.baseText('settings.usageAndPlan.button.activation') }} + {{ $locale.baseText('settings.usageAndPlan.button.activation') }} {{ $locale.baseText('settings.usageAndPlan.button.manage') }} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd25e75922548..ebfe84d5062c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -540,7 +540,7 @@ importers: vue-i18n: ^8.26.7 vue-json-pretty: 1.9.3 vue-prism-editor: ^0.3.0 - vue-router: ^3.0.6 + vue-router: ^3.6.5 vue-template-compiler: ^2.7 vue-tsc: ^0.35.0 vue-typed-mixins: ^0.2.0 From 9fb88bc68c4db8448eabc88eeea1f1b483f1f207 Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 9 Dec 2022 13:33:52 +0100 Subject: [PATCH 16/78] chore(editor): code formatting --- packages/editor-ui/src/stores/usage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor-ui/src/stores/usage.ts b/packages/editor-ui/src/stores/usage.ts index a732a5033c4d3..7fd77e7d8296f 100644 --- a/packages/editor-ui/src/stores/usage.ts +++ b/packages/editor-ui/src/stores/usage.ts @@ -2,7 +2,7 @@ import { computed, reactive } from 'vue'; import { defineStore } from 'pinia'; import { useRoute } from 'vue-router/composables'; import { UsageState } from '@/Interface'; -import {activateLicenseKey, getLicense, renewLicense} from '@/api/usage'; +import { activateLicenseKey, getLicense, renewLicense } from '@/api/usage'; import { useRootStore } from '@/stores/n8nRootStore'; import { useSettingsStore } from "@/stores/settings"; import { useUsersStore } from "@/stores/users"; From ea6029b484bc76b76c64ea154a3ab22f134a8bb6 Mon Sep 17 00:00:00 2001 From: freyamade Date: Fri, 9 Dec 2022 13:53:36 +0000 Subject: [PATCH 17/78] :rotating_light: - added license api tests --- .../cli/test/integration/license.api.test.ts | 188 ++++++++++++++++++ .../cli/test/integration/shared/types.d.ts | 3 +- packages/cli/test/integration/shared/utils.ts | 4 +- 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 packages/cli/test/integration/license.api.test.ts diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts new file mode 100644 index 0000000000000..b7660537641a0 --- /dev/null +++ b/packages/cli/test/integration/license.api.test.ts @@ -0,0 +1,188 @@ +import express from 'express'; + +import config from '@/config'; +import type { Role } from '@db/entities/Role'; +import * as testDb from './shared/testDb'; +import type { AuthAgent } from './shared/types'; +import * as utils from './shared/utils'; +import { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; +import { LicenseManager } from '@n8n_io/license-sdk'; +import { License } from '@/License'; + +jest.mock('@/telemetry'); +jest.mock('@n8n_io/license-sdk', ); + +const MOCK_SERVER_URL = 'https://server.com/v1'; +const MOCK_RENEW_OFFSET = 259200; +const MOCK_INSTANCE_ID = 'instance-id'; +const MOCK_N8N_VERSION = '0.27.0'; + +let app: express.Application; +let testDbName = ''; +let globalOwnerRole: Role; +let globalMemberRole: Role; +let authAgent: AuthAgent; +let license: License; + +beforeAll(async () => { + app = await utils.initTestServer({ endpointGroups: ['license'], applyAuth: true }); + const initResult = await testDb.init(); + testDbName = initResult.testDbName; + + globalOwnerRole = await testDb.getGlobalOwnerRole(); + globalMemberRole = await testDb.getGlobalMemberRole(); + + authAgent = utils.createAuthAgent(app); + + utils.initTestLogger(); + utils.initTestTelemetry(); + + config.set('license.serverUrl', MOCK_SERVER_URL); + config.set('license.autoRenewEnabled', true); + config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); +}); + +beforeEach(async () => { + license = new License(); + await license.init(MOCK_INSTANCE_ID, MOCK_N8N_VERSION); +}); + +afterEach(async () => { + await testDb.truncate(['Settings'], testDbName); +}); + +afterAll(async () => { + await testDb.terminate(testDbName); +}); + +test('GET /license should return license information to the instance owner', async () => { + const userShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(userShell).get('/license'); + + expect(response.statusCode).toBe(200); + + // No license defined so we just expect the result to be the defaults + expect(response.body).toStrictEqual(DEFAULT_LICENSE_RESPONSE); +}); + +test('GET /license should return license information to a regular user', async () => { + const userShell = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(userShell).get('/license'); + + expect(response.statusCode).toBe(200); + + // No license defined so we just expect the result to be the defaults + expect(response.body).toStrictEqual(DEFAULT_LICENSE_RESPONSE); +}); + +test('POST /license/activate should work for instance owner', async () => { + const userShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(userShell) + .post('/license/activate') + .send({ activationKey: 'abcde' }); + + expect(response.statusCode).toBe(200); + + // No license defined so we just expect the result to be the defaults + expect(response.body).toStrictEqual(DEFAULT_POST_RESPONSE); +}); + +test('POST /license/activate does not work for regular users', async () => { + const userShell = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(userShell) + .post('/license/activate') + .send({ activationKey: 'abcde' }); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe(NON_OWNER_ACTIVATE_MESSAGE); +}); + +test('POST /license/activate errors out properly', async () => { + License.prototype.activate = jest.fn().mockImplementation(() => { + throw new Error(INVALID_ACIVATION_KEY_MESSAGE); + }); + + const userShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(userShell) + .post('/license/activate') + .send({ activationKey: 'abcde' }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe(INVALID_ACIVATION_KEY_MESSAGE); +}); + +test('POST /license/renew should work for instance owner', async () => { + const userShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(userShell).post('/license/renew'); + + expect(response.statusCode).toBe(200); + + // No license defined so we just expect the result to be the defaults + expect(response.body).toStrictEqual(DEFAULT_POST_RESPONSE); +}); + +test('POST /license/renew does not work for regular users', async () => { + const userShell = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(userShell).post('/license/renew'); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe(NON_OWNER_RENEW_MESSAGE); +}); + +test('POST /license/renew errors out properly', async () => { + License.prototype.renew = jest.fn().mockImplementation(() => { + throw new Error(RENEW_ERROR_MESSAGE); + }); + + const userShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(userShell).post('/license/renew'); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe(RENEW_ERROR_MESSAGE); +}); + +const DEFAULT_LICENSE_RESPONSE: { data: ILicenseReadResponse } = { + data: { + usage: { + executions: { + value: 0, + limit: -1, + warningThreshold: 0.8, + }, + }, + license: { + planId: '', + planName: 'Community', + }, + }, +}; + +const DEFAULT_POST_RESPONSE: { data: ILicensePostResponse } = { + data: { + usage: { + executions: { + value: 0, + limit: -1, + warningThreshold: 0.8, + }, + }, + license: { + planId: '', + planName: 'Community', + }, + managementToken: '', + }, +}; + +const NON_OWNER_ACTIVATE_MESSAGE = 'Only an instance owner may activate a license'; +const NON_OWNER_RENEW_MESSAGE = 'Only an instance owner may renew a license'; +const INVALID_ACIVATION_KEY_MESSAGE = 'Invalid activation key'; +const RENEW_ERROR_MESSAGE = 'Something went wrong when trying to renew license'; diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index 5a87e96d9d4c2..318d58f655c8b 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -23,7 +23,8 @@ type EndpointGroup = | 'credentials' | 'workflows' | 'publicApi' - | 'nodes'; + | 'nodes' + | 'license'; export type CredentialPayload = { name: string; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index 1f20a965c8a59..bcf48ce036745 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -65,6 +65,7 @@ import type { InstalledPackagePayload, PostgresSchemaSection, } from './types'; +import { licenseController } from '@/license/license.controller'; const loadNodesAndCredentials: INodesAndCredentials = { loaded: { nodes: {}, credentials: {} }, @@ -123,6 +124,7 @@ export async function initTestServer({ credentials: { controller: credentialsController, path: 'credentials' }, workflows: { controller: workflowsController, path: 'workflows' }, nodes: { controller: nodesController, path: 'nodes' }, + license: { controller: licenseController, path: 'license' }, publicApi: apiRouters, }; @@ -167,7 +169,7 @@ const classifyEndpointGroups = (endpointGroups: string[]) => { const routerEndpoints: string[] = []; const functionEndpoints: string[] = []; - const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi']; + const ROUTER_GROUP = ['credentials', 'nodes', 'workflows', 'publicApi', 'license']; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), From 0e91cfe87074b33ab1044fb93d8479a0c7d346ef Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Fri, 9 Dec 2022 15:47:12 +0100 Subject: [PATCH 18/78] fix(editor): usage store --- packages/editor-ui/src/stores/usage.ts | 4 +--- packages/editor-ui/src/views/SettingsUsageAndPlan.vue | 6 ++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/editor-ui/src/stores/usage.ts b/packages/editor-ui/src/stores/usage.ts index 7fd77e7d8296f..7c60fc5e61747 100644 --- a/packages/editor-ui/src/stores/usage.ts +++ b/packages/editor-ui/src/stores/usage.ts @@ -1,6 +1,5 @@ import { computed, reactive } from 'vue'; import { defineStore } from 'pinia'; -import { useRoute } from 'vue-router/composables'; import { UsageState } from '@/Interface'; import { activateLicenseKey, getLicense, renewLicense } from '@/api/usage'; import { useRootStore } from '@/stores/n8nRootStore'; @@ -32,7 +31,6 @@ export const useUsageStore = defineStore('usage', () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); const usersStore = useUsersStore(); - const route = useRoute(); const state = reactive(DEFAULT_STATE); @@ -85,7 +83,7 @@ export const useUsageStore = defineStore('usage', () => { isCloseToLimit: computed(() => state.data.usage.executions.limit < 0 ? false : state.data.usage.executions.value / state.data.usage.executions.limit >= state.data.usage.executions.warningThreshold), instanceId: computed(() => settingsStore.settings.instanceId), managementToken: computed(() => state.data.managementToken), - viewPlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}?instanceId=${settingsStore.settings.instanceId}&callback=${encodeURIComponent(`${window.location.origin}${route.fullPath}`)}`), + viewPlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}?instanceId=${settingsStore.settings.instanceId}`), managePlansUrl: computed(() => `${SUBSCRIPTION_APP_URL}/manage?token=${state.data.managementToken}`), canUserActivateLicense: computed(() => usersStore.canUserActivateLicense), }; diff --git a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue index 0b01a1f70122e..b03b6cd0e8715 100644 --- a/packages/editor-ui/src/views/SettingsUsageAndPlan.vue +++ b/packages/editor-ui/src/views/SettingsUsageAndPlan.vue @@ -1,11 +1,13 @@