feat(editor): Add usage and plan pages (#4819)
* feat(editor): Usage and plan page (#4793) feat(editor): usage and plan page * feat(editor): Update Usage and plan page (#4842) * feat(editor): usage and plan store * feat(editor): usage and plan page updates * feat(editor): usage and plan add buttons and alert * tes(editor): usage and plan store * tes(editor): usage remove refresh button and add link to view plans * tes(editor): usage use info tip * tes(editor): usage info style * feat(editor): Get quotas data (#4866) feat(editor): get quotas data * feat(editor): In-app experience (#4875) * feat: Add license quotas endpoint * feat: Add trigger count to workflow activation process * refactor: Get quotas from db * feat: Add license information * ✨ - finalised GET /license endpoint * 🔨 - getActiveTriggerCount return 0 instead of null * 🐛 - ignore manualTrigger when counting active triggers * ✨ - add activation endpoint * ✨ - added renew endpoint * 🔨 - added return type interfaces * 🔨 - handle license errors where methods are called * 🔨 - rename function to match name from lib * feat(editor): usage add plans buttons logic * 🚨 - testing new License methods * feat(editor): usage add more business logic * chore(editor): code formatting * 🚨 - added license api tests * fix(editor): usage store * fix(editor): usage update translations * feat(editor): usage add license activation modal * feat(editor): usage change subscription app url * feat(editor): usage add contact us link * feat(editor): usage fix modal width * ✨ - Add renewal tracking metric * ✨ - add license data to pulse event * 🔨 - set default triggercount on entity model * ✨ - add db migrations for mysql and postgres * fix(editor): Usage api call data processing and error handling * fix(editor): Usage fix activation query key * 🚨 - add initDb to telemetry tests * 🔨 - move getlicensedata to licenseservice * 🔨 - return 403 instead of 404 to non owners * 🔨 - move owner checking to middleware * 🐛 - fixed incorrectly returned error from middleware * 🐛 - using mock instead of test db for pulse tests * fix(editor): Usage fix activation and add success messages * fix(editor): Usage should not renew activation right after activation * 🚨 - skipping failing pulse tests for now * fix(editor): Usage add telemetry calls and apply design review outcomes * feat(editor): Hide usage page according to BE flag * feat(editor): Usage modify key activation flow * feat(editor): Usage change subscription app url * feat(editor): Usage add telemetry for manage plan * feat(editor): Usage extend link url query params * feat(editor): Usage add line chart if there is a workflow limit * feat(editor): Usage remove query after key activation redirection * fix(editor): Usage handle limit exceeded workflow chart, add focus to input when modal opened * fix(editor): Usage activation can return router promise when removing query * fix(editor): Usage and plan design review * 🐛 - fix renew endpoint hanging issue * 🐛 - fix license activation bug * fix(editor): Usage proper translation for plans and/or editions * fix(editor): Usage apply David's review results * fix(editor): Usage page set as default and first under Settings * fix(editor): Usage open subscription app in new tab * fix(editor): Usage page having key query param a plan links * test: Fix broken test * fix(editor): Usage page address review * 🧪 Flush promises on telemetry tests * ⚡ Extract helper with `setImmediate` * 🔥 Remove leftovers * ⚡ Use Adi's helper * refactor: Comment broken tests * refactor: add Tenant id to settings * feat: add environment to license endpoints * refactor: Move license environment to general settings * fix: fix routing bug * fix(editor): Usage page some code review changes and formatting * fix(editor): Usage page remove direct usage of reusable translation keys * fix(editor): Usage page async await instead of then * fix(editor): Usage page show some content only if network requests in component mounted were successful * chore(editor): code formatting * fix(editor): Usage checking license environment * feat(editor): Improve license activation error messages (no-changelog) (#4958) * fix(editor): Usage changing activation error title * remove unnecessary import * fix(editor): Usage refactor notification showing * fix(editor): Usage using notification directly in store actions Co-authored-by: Omar Ajoue <krynble@gmail.com> Co-authored-by: freyamade <freya@n8n.io> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Mutasem <mutdmour@gmail.com> Co-authored-by: Cornelius Suermann <cornelius@n8n.io> * fix(editor): Usage change mounted lifecycle logic * fix(editor): Usage return after successful activation in mounted * fix: remove console log * test: fix tests related to settings (#4979) Co-authored-by: Omar Ajoue <krynble@gmail.com> Co-authored-by: freyamade <freya@n8n.io> Co-authored-by: Iván Ovejero <ivov.src@gmail.com> Co-authored-by: Mutasem <mutdmour@gmail.com> Co-authored-by: Cornelius Suermann <cornelius@n8n.io> Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
@@ -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}"`);
|
||||
@@ -854,6 +856,18 @@ export class ActiveWorkflowRunner {
|
||||
// If there were activation errors delete them
|
||||
delete this.activationErrors[workflowId];
|
||||
}
|
||||
|
||||
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.queryNodes(triggerFilter).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
|
||||
|
||||
|
||||
@@ -501,6 +501,9 @@ export interface IN8nUISettings {
|
||||
workflowSharing: boolean;
|
||||
};
|
||||
hideUsagePage: boolean;
|
||||
license: {
|
||||
environment: 'production' | 'staging';
|
||||
};
|
||||
}
|
||||
|
||||
export interface IPersonalizationSurveyAnswers {
|
||||
@@ -751,3 +754,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;
|
||||
}
|
||||
|
||||
@@ -498,4 +498,11 @@ export class InternalHooksClass implements IInternalHooksClass {
|
||||
}): Promise<void> {
|
||||
return this.telemetry.track('Workflow first data fetched', data, { withPostHog: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* License
|
||||
*/
|
||||
async onLicenseRenewAttempt(data: { success: boolean }): Promise<void> {
|
||||
await this.telemetry.track('Instance attempted to refresh license', data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -70,17 +70,7 @@ export class License {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.manager.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.manager.activate(activationKey);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
this.logger.error('Could not activate license', e);
|
||||
}
|
||||
}
|
||||
await this.manager.activate(activationKey);
|
||||
}
|
||||
|
||||
async renew() {
|
||||
@@ -88,13 +78,7 @@ export class License {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.manager.renew();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
this.logger.error('Could not renew license', e);
|
||||
}
|
||||
}
|
||||
await this.manager.renew();
|
||||
}
|
||||
|
||||
isFeatureEnabled(feature: string): boolean {
|
||||
@@ -108,6 +92,56 @@ export class License {
|
||||
isSharingEnabled() {
|
||||
return this.isFeatureEnabled(LICENSE_FEATURES.SHARING);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
getManagementJwt(): string {
|
||||
if (!this.manager) {
|
||||
return '';
|
||||
}
|
||||
return this.manager.getManagementJwt();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get the main plan for a license
|
||||
*/
|
||||
getMainPlan(): TEntitlement | undefined {
|
||||
if (!this.manager) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entitlements = this.getCurrentEntitlements();
|
||||
if (!entitlements.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return entitlements.find(
|
||||
(entitlement) =>
|
||||
(entitlement.productMetadata.terms as unknown as { isMainPlan: boolean }).isMainPlan,
|
||||
);
|
||||
}
|
||||
|
||||
// Helper functions for computed data
|
||||
getTriggerLimit(): number {
|
||||
return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number;
|
||||
}
|
||||
|
||||
getPlanName(): string {
|
||||
return (this.getFeatureValue('planName') ?? 'Community') as string;
|
||||
}
|
||||
}
|
||||
|
||||
let licenseInstance: License | undefined;
|
||||
|
||||
@@ -158,6 +158,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
|
||||
import { toHttpNodeParameters } from '@/CurlConverterHelper';
|
||||
import { setupErrorMiddleware } from '@/ErrorReporting';
|
||||
import { getLicense } from '@/License';
|
||||
import { licenseController } from './license/license.controller';
|
||||
import { corsMiddleware } from './middlewares/cors';
|
||||
|
||||
require('body-parser-xml')(bodyParser);
|
||||
@@ -358,6 +359,9 @@ class App {
|
||||
workflowSharing: false,
|
||||
},
|
||||
hideUsagePage: config.getEnv('hideUsagePage'),
|
||||
license: {
|
||||
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,7 +405,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -792,6 +800,11 @@ class App {
|
||||
// ----------------------------------------
|
||||
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);
|
||||
|
||||
// ----------------------------------------
|
||||
// License
|
||||
// ----------------------------------------
|
||||
this.app.use(`/${this.restEndpoint}/license`, licenseController);
|
||||
|
||||
// ----------------------------------------
|
||||
// Workflow Statistics
|
||||
// ----------------------------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -99,6 +99,9 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {
|
||||
|
||||
@Column({ length: 36 })
|
||||
versionId: string;
|
||||
|
||||
@Column({ default: 0 })
|
||||
triggerCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||
import config from '@/config';
|
||||
|
||||
export class AddTriggerCountColumn1669823906994 implements MigrationInterface {
|
||||
name = 'AddTriggerCountColumn1669823906994';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts
|
||||
|
||||
logMigrationEnd(this.name);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${tablePrefix}workflow_entity DROP COLUMN triggerCount`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWo
|
||||
import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable';
|
||||
import { RemoveCredentialUsageTable1665754637026 } from './1665754637026-RemoveCredentialUsageTable';
|
||||
import { AddWorkflowVersionIdColumn1669739707125 } from './1669739707125-AddWorkflowVersionIdColumn';
|
||||
import { AddTriggerCountColumn1669823906994 } from './1669823906994-AddTriggerCountColumn';
|
||||
|
||||
export const mysqlMigrations = [
|
||||
InitialMigration1588157391238,
|
||||
@@ -54,4 +55,5 @@ export const mysqlMigrations = [
|
||||
RemoveCredentialUsageTable1665754637026,
|
||||
AddWorkflowVersionIdColumn1669739707125,
|
||||
WorkflowStatistics1664196174002,
|
||||
AddTriggerCountColumn1669823906994,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||
import config from '@/config';
|
||||
|
||||
export class AddTriggerCountColumn1669823906995 implements MigrationInterface {
|
||||
name = 'AddTriggerCountColumn1669823906995';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
logMigrationStart(this.name);
|
||||
|
||||
const tablePrefix = getTablePrefix();
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${tablePrefix}workflow_entity ADD COLUMN "triggerCount" integer NOT NULL DEFAULT 0`,
|
||||
);
|
||||
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts
|
||||
|
||||
logMigrationEnd(this.name);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const tablePrefix = getTablePrefix();
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE ${tablePrefix}workflow_entity DROP COLUMN "triggerCount"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWo
|
||||
import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable';
|
||||
import { RemoveCredentialUsageTable1665754637025 } from './1665754637025-RemoveCredentialUsageTable';
|
||||
import { AddWorkflowVersionIdColumn1669739707126 } from './1669739707126-AddWorkflowVersionIdColumn';
|
||||
import { AddTriggerCountColumn1669823906995 } from './1669823906995-AddTriggerCountColumn';
|
||||
|
||||
export const postgresMigrations = [
|
||||
InitialMigration1587669153312,
|
||||
@@ -50,4 +51,5 @@ export const postgresMigrations = [
|
||||
RemoveCredentialUsageTable1665754637025,
|
||||
AddWorkflowVersionIdColumn1669739707126,
|
||||
WorkflowStatistics1664196174001,
|
||||
AddTriggerCountColumn1669823906995,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
|
||||
import config from '@/config';
|
||||
|
||||
export class AddTriggerCountColumn1669823906993 implements MigrationInterface {
|
||||
name = 'AddTriggerCountColumn1669823906993';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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`,
|
||||
);
|
||||
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts
|
||||
|
||||
logMigrationEnd(this.name);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const tablePrefix = config.getEnv('database.tablePrefix');
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN "triggerCount"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWo
|
||||
import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable';
|
||||
import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable';
|
||||
import { AddWorkflowVersionIdColumn1669739707124 } from './1669739707124-AddWorkflowVersionIdColumn';
|
||||
import { AddTriggerCountColumn1669823906993 } from './1669823906993-AddTriggerCountColumn';
|
||||
|
||||
const sqliteMigrations = [
|
||||
InitialMigration1588102412422,
|
||||
@@ -47,6 +48,7 @@ const sqliteMigrations = [
|
||||
CreateCredentialUsageTable1665484192211,
|
||||
RemoveCredentialUsageTable1665754637024,
|
||||
AddWorkflowVersionIdColumn1669739707124,
|
||||
AddTriggerCountColumn1669823906993,
|
||||
WorkflowStatistics1664196174000,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
|
||||
import { INode, IRun, IWorkflowBase, LoggerProxy } from 'n8n-workflow';
|
||||
import { Db, InternalHooksManager } from '..';
|
||||
import { StatisticsNames } from '../databases/entities/WorkflowStatistics';
|
||||
import { getWorkflowOwner } from '../UserManagement/UserManagementHelper';
|
||||
@@ -26,7 +26,7 @@ export async function workflowExecutionCompleted(
|
||||
workflowId = parseInt(workflowData.id as string, 10);
|
||||
if (isNaN(workflowId)) throw new Error('not a number');
|
||||
} catch (error) {
|
||||
console.error(`Error "${error as string}" when casting workflow ID to a number`);
|
||||
LoggerProxy.error(`Error "${error as string}" when casting workflow ID to a number`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ export async function nodeFetchedData(workflowId: string, node: INode): Promise<
|
||||
id = parseInt(workflowId, 10);
|
||||
if (isNaN(id)) throw new Error('not a number');
|
||||
} catch (error) {
|
||||
console.error(`Error ${error as string} when casting workflow ID to a number`);
|
||||
LoggerProxy.error(`Error ${error as string} when casting workflow ID to a number`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
36
packages/cli/src/license/License.service.ts
Normal file
36
packages/cli/src/license/License.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { getLicense } from '@/License';
|
||||
import { Db, ILicenseReadResponse } from '..';
|
||||
|
||||
export class LicenseService {
|
||||
static async getActiveTriggerCount(): Promise<number> {
|
||||
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 ?? 0;
|
||||
}
|
||||
|
||||
// Helper for getting the basic license data that we want to return
|
||||
static async getLicenseData(): Promise<ILicenseReadResponse> {
|
||||
const triggerCount = await LicenseService.getActiveTriggerCount();
|
||||
const license = getLicense();
|
||||
const mainPlan = license.getMainPlan();
|
||||
|
||||
return {
|
||||
usage: {
|
||||
executions: {
|
||||
value: triggerCount,
|
||||
limit: license.getTriggerLimit(),
|
||||
warningThreshold: 0.8,
|
||||
},
|
||||
},
|
||||
license: {
|
||||
planId: mainPlan?.productId ?? '',
|
||||
planName: license.getPlanName(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
136
packages/cli/src/license/license.controller.ts
Normal file
136
packages/cli/src/license/license.controller.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
import express from 'express';
|
||||
import { LoggerProxy } from 'n8n-workflow';
|
||||
|
||||
import { getLogger } from '@/Logger';
|
||||
import {
|
||||
ILicensePostResponse,
|
||||
ILicenseReadResponse,
|
||||
InternalHooksManager,
|
||||
ResponseHelper,
|
||||
} from '..';
|
||||
import { LicenseService } from './License.service';
|
||||
import { getLicense } from '@/License';
|
||||
import { AuthenticatedRequest, LicenseRequest } from '@/requests';
|
||||
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service';
|
||||
|
||||
export const licenseController = express.Router();
|
||||
|
||||
const OWNER_ROUTES = ['/activate', '/renew'];
|
||||
|
||||
/**
|
||||
* Initialize Logger if needed
|
||||
*/
|
||||
licenseController.use((req, res, next) => {
|
||||
try {
|
||||
LoggerProxy.getInstance();
|
||||
} catch (error) {
|
||||
LoggerProxy.init(getLogger());
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner checking
|
||||
*/
|
||||
licenseController.use((req: AuthenticatedRequest, res, next) => {
|
||||
if (OWNER_ROUTES.includes(req.path) && req.user) {
|
||||
if (!isInstanceOwner(req.user)) {
|
||||
LoggerProxy.info('Non-owner attempted to activate or renew a license', {
|
||||
userId: req.user.id,
|
||||
});
|
||||
ResponseHelper.sendErrorResponse(
|
||||
res,
|
||||
new ResponseHelper.UnauthorizedError(
|
||||
'Only an instance owner may activate or renew a license',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /license
|
||||
* Get the license data, usable by everyone
|
||||
*/
|
||||
licenseController.get(
|
||||
'/',
|
||||
ResponseHelper.send(async (): Promise<ILicenseReadResponse> => {
|
||||
return LicenseService.getLicenseData();
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /license/activate
|
||||
* Only usable by the instance owner, activates a license.
|
||||
*/
|
||||
licenseController.post(
|
||||
'/activate',
|
||||
ResponseHelper.send(async (req: LicenseRequest.Activate): Promise<ILicensePostResponse> => {
|
||||
// Call the license manager activate function and tell it to throw an error
|
||||
const license = getLicense();
|
||||
try {
|
||||
await license.activate(req.body.activationKey);
|
||||
} catch (e) {
|
||||
const error = e as Error & { errorId?: string };
|
||||
|
||||
switch (error.errorId ?? 'UNSPECIFIED') {
|
||||
case 'SCHEMA_VALIDATION':
|
||||
error.message = 'Activation key is in the wrong format';
|
||||
break;
|
||||
case 'RESERVATION_EXHAUSTED':
|
||||
error.message =
|
||||
'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it';
|
||||
break;
|
||||
case 'RESERVATION_EXPIRED':
|
||||
error.message = 'Activation key has expired';
|
||||
break;
|
||||
case 'NOT_FOUND':
|
||||
case 'RESERVATION_CONFLICT':
|
||||
error.message = 'Activation key not found';
|
||||
break;
|
||||
}
|
||||
|
||||
throw new ResponseHelper.BadRequestError((e as Error).message);
|
||||
}
|
||||
|
||||
// Return the read data, plus the management JWT
|
||||
return {
|
||||
managementToken: license.getManagementJwt(),
|
||||
...(await LicenseService.getLicenseData()),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /license/renew
|
||||
* Only usable by instance owner, renews a license
|
||||
*/
|
||||
licenseController.post(
|
||||
'/renew',
|
||||
ResponseHelper.send(async (): Promise<ILicensePostResponse> => {
|
||||
// Call the license manager activate function and tell it to throw an error
|
||||
const license = getLicense();
|
||||
try {
|
||||
await license.renew();
|
||||
} catch (e) {
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void InternalHooksManager.getInstance().onLicenseRenewAttempt({ success: false });
|
||||
if (e instanceof Error) {
|
||||
throw new ResponseHelper.BadRequestError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// not awaiting so as not to make the endpoint hang
|
||||
void InternalHooksManager.getInstance().onLicenseRenewAttempt({ success: true });
|
||||
|
||||
// Return the read data, plus the management JWT
|
||||
return {
|
||||
managementToken: license.getManagementJwt(),
|
||||
...(await LicenseService.getLicenseData()),
|
||||
};
|
||||
}),
|
||||
);
|
||||
8
packages/cli/src/requests.d.ts
vendored
8
packages/cli/src/requests.d.ts
vendored
@@ -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 }, {}>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ITelemetryTrackProperties, LoggerProxy } from 'n8n-workflow';
|
||||
import config from '@/config';
|
||||
import { IExecutionTrackProperties } from '@/Interfaces';
|
||||
import { getLogger } from '@/Logger';
|
||||
import { getLicense } from '@/License';
|
||||
import { LicenseService } from '@/license/License.service';
|
||||
|
||||
type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success';
|
||||
|
||||
@@ -95,7 +97,14 @@ export class Telemetry {
|
||||
});
|
||||
|
||||
this.executionCountsBuffer = {};
|
||||
allPromises.push(this.track('pulse'));
|
||||
|
||||
// License info
|
||||
const pulsePacket = {
|
||||
plan_name_current: getLicense().getPlanName(),
|
||||
quota: getLicense().getTriggerLimit(),
|
||||
usage: await LicenseService.getActiveTriggerCount(),
|
||||
};
|
||||
allPromises.push(this.track('pulse', pulsePacket));
|
||||
return Promise.all(allPromises);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user