diff --git a/.vscode/settings.default.json b/.vscode/settings.default.json index 6532df1beec88..f98d5cec70401 100644 --- a/.vscode/settings.default.json +++ b/.vscode/settings.default.json @@ -9,7 +9,6 @@ "typescript.format.enable": false, "typescript.tsdk": "node_modules/typescript/lib", "workspace-default-settings.runOnActivation": true, - "prettier.prettierPath": "node_modules/prettier", "eslint.probe": ["javascript", "typescript", "vue"], "eslint.workingDirectories": [ { diff --git a/cypress/e2e/27-opt-in-trial-banner.cy.ts b/cypress/e2e/27-opt-in-trial-banner.cy.ts new file mode 100644 index 0000000000000..0f66236bb0e69 --- /dev/null +++ b/cypress/e2e/27-opt-in-trial-banner.cy.ts @@ -0,0 +1,67 @@ +import { BannerStack, MainSidebar, WorkflowPage } from '../pages'; +import planData from '../fixtures/Plan_data_opt_in_trial.json'; +import { INSTANCE_OWNER } from '../constants'; + +const mainSidebar = new MainSidebar(); +const bannerStack = new BannerStack(); +const workflowPage = new WorkflowPage(); + +describe('BannerStack', { disableAutoLogin: true }, () => { + before(() => { + const now = new Date(); + const fiveDaysFromNow = new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000); + planData.expirationDate = fiveDaysFromNow.toJSON(); + }); + + it('should render trial banner for opt-in cloud user', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'cloud' }, n8nMetadata: { userId: 1 } }, + }); + }); + }).as('loadSettings'); + + cy.intercept('GET', '/rest/admin/cloud-plan', { + body: planData, + }).as('getPlanData'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + cy.wait('@getPlanData'); + + bannerStack.getters.banner().should('be.visible'); + + mainSidebar.actions.signout(); + + bannerStack.getters.banner().should('not.be.visible'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + bannerStack.getters.banner().should('be.visible'); + + mainSidebar.actions.signout(); + }); + + it('should not render opt-in-trial banner for non cloud deployment', () => { + cy.intercept('GET', '/rest/settings', (req) => { + req.on('response', (res) => { + res.send({ + data: { ...res.body.data, deployment: { type: 'default' } }, + }); + }); + }).as('loadSettings'); + + cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); + + cy.visit(workflowPage.url); + + bannerStack.getters.banner().should('not.be.visible'); + + mainSidebar.actions.signout(); + }); +}); diff --git a/cypress/fixtures/Plan_data_opt_in_trial.json b/cypress/fixtures/Plan_data_opt_in_trial.json new file mode 100644 index 0000000000000..504805de320e1 --- /dev/null +++ b/cypress/fixtures/Plan_data_opt_in_trial.json @@ -0,0 +1,29 @@ +{ + "id": 200, + "planId": 1, + "pruneExecutionsInterval": 168, + "monthlyExecutionsLimit": 1000, + "activeWorkflowsLimit": 20, + "credentialsLimit": 100, + "supportTier": "community", + "displayName": "Trial", + "enabledFeatures": ["userManagement", "advancedExecutionFilters", "sharing"], + "licenseFeatures": { + "feat:sharing": true, + "feat:advancedExecutionFilters": true, + "quota:users": -1, + "quota:maxVariables": -1, + "feat:variables": true, + "feat:apiDisabled": true + }, + "metadata": { + "version": "v1", + "group": "trial", + "slug": "trial-2", + "trial": { + "length": 14, + "gracePeriod": 3 + } + }, + "expirationDate": "2023-08-30T15:47:27.611Z" +} diff --git a/cypress/pages/bannerStack.ts b/cypress/pages/bannerStack.ts new file mode 100644 index 0000000000000..dce3222126018 --- /dev/null +++ b/cypress/pages/bannerStack.ts @@ -0,0 +1,8 @@ +import { BasePage } from './base'; + +export class BannerStack extends BasePage { + getters = { + banner: () => cy.getByTestId('banner-stack'), + }; + actions = {}; +} diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index 33ddcda6e53b5..4d95611a12129 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -7,3 +7,4 @@ export * from './settings-users'; export * from './settings-log-streaming'; export * from './sidebar'; export * from './ndv'; +export * from './bannerStack'; diff --git a/cypress/pages/sidebar/main-sidebar.ts b/cypress/pages/sidebar/main-sidebar.ts index fc9d8557a255a..789d63545f6e9 100644 --- a/cypress/pages/sidebar/main-sidebar.ts +++ b/cypress/pages/sidebar/main-sidebar.ts @@ -1,4 +1,5 @@ import { BasePage } from '../base'; +import { WorkflowsPage } from '../workflows'; export class MainSidebar extends BasePage { getters = { @@ -9,7 +10,7 @@ export class MainSidebar extends BasePage { workflows: () => this.getters.menuItem('Workflows'), credentials: () => this.getters.menuItem('Credentials'), executions: () => this.getters.menuItem('Executions'), - userMenu: () => cy.getByTestId('main-sidebar-user-menu'), + userMenu: () => cy.get('div[class="action-dropdown-container"]'), }; actions = { goToSettings: () => { @@ -26,5 +27,15 @@ export class MainSidebar extends BasePage { openUserMenu: () => { this.getters.userMenu().find('[role="button"]').last().click(); }, + openUserMenu: () => { + this.getters.userMenu().click(); + }, + signout: () => { + const workflowsPage = new WorkflowsPage(); + cy.visit(workflowsPage.url); + this.actions.openUserMenu(); + cy.getByTestId('user-menu-item-logout').click(); + cy.wrap(Cypress.session.clearAllSavedSessions()); + }, }; } diff --git a/packages/cli/src/ActiveWebhooks.ts b/packages/cli/src/ActiveWebhooks.ts index 14e4f56a13b5f..42e2425634898 100644 --- a/packages/cli/src/ActiveWebhooks.ts +++ b/packages/cli/src/ActiveWebhooks.ts @@ -63,25 +63,13 @@ export class ActiveWebhooks { this.webhookUrls[webhookKey].push(webhookData); try { - const webhookExists = await workflow.runWebhookMethod( - 'checkExists', + await workflow.createWebhookIfNotExists( webhookData, NodeExecuteFunctions, mode, activation, this.testWebhooks, ); - if (webhookExists !== true) { - // If webhook does not exist yet create it - await workflow.runWebhookMethod( - 'create', - webhookData, - NodeExecuteFunctions, - mode, - activation, - this.testWebhooks, - ); - } } catch (error) { // If there was a problem unregister the webhook again if (this.webhookUrls[webhookKey].length <= 1) { @@ -183,8 +171,7 @@ export class ActiveWebhooks { // Go through all the registered webhooks of the workflow and remove them for (const webhookData of webhooks) { - await workflow.runWebhookMethod( - 'delete', + await workflow.deleteWebhook( webhookData, NodeExecuteFunctions, mode, diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index 688a819c1050e..f830becb538a7 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -392,25 +392,13 @@ export class ActiveWorkflowRunner implements IWebhookManager { try { // TODO: this should happen in a transaction, that way we don't need to manually remove this in `catch` await this.webhookService.storeWebhook(webhook); - const webhookExists = await workflow.runWebhookMethod( - 'checkExists', + await workflow.createWebhookIfNotExists( webhookData, NodeExecuteFunctions, mode, activation, false, ); - if (webhookExists !== true) { - // If webhook does not exist yet create it - await workflow.runWebhookMethod( - 'create', - webhookData, - NodeExecuteFunctions, - mode, - activation, - false, - ); - } } catch (error) { if ( activation === 'init' && @@ -489,14 +477,7 @@ export class ActiveWorkflowRunner implements IWebhookManager { const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true); for (const webhookData of webhooks) { - await workflow.runWebhookMethod( - 'delete', - webhookData, - NodeExecuteFunctions, - mode, - 'update', - false, - ); + await workflow.deleteWebhook(webhookData, NodeExecuteFunctions, mode, 'update', false); } await WorkflowHelpers.saveStaticData(workflow); diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 0a0b636998793..ebd3bc2570d86 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -124,7 +124,6 @@ export class InternalHooks implements IInternalHooksClass { return this.telemetry.track( 'User responded to personalization questions', personalizationSurveyData, - { withPostHog: true }, ); } @@ -190,21 +189,17 @@ export class InternalHooks implements IInternalHooksClass { workflowName: workflow.name, }, }), - this.telemetry.track( - 'User saved workflow', - { - user_id: user.id, - workflow_id: workflow.id, - node_graph_string: JSON.stringify(nodeGraph), - notes_count_overlapping: overlappingCount, - notes_count_non_overlapping: notesCount - overlappingCount, - version_cli: N8N_VERSION, - num_tags: workflow.tags?.length ?? 0, - public_api: publicApi, - sharing_role: userRole, - }, - { withPostHog: true }, - ), + this.telemetry.track('User saved workflow', { + user_id: user.id, + workflow_id: workflow.id, + node_graph_string: JSON.stringify(nodeGraph), + notes_count_overlapping: overlappingCount, + notes_count_non_overlapping: notesCount - overlappingCount, + version_cli: N8N_VERSION, + num_tags: workflow.tags?.length ?? 0, + public_api: publicApi, + sharing_role: userRole, + }), ]); } @@ -415,11 +410,7 @@ export class InternalHooks implements IInternalHooksClass { node_id: nodeGraphResult.nameIndices[runData.data.startData?.destinationNode], }; - promises.push( - this.telemetry.track('Manual node exec finished', telemetryPayload, { - withPostHog: true, - }), - ); + promises.push(this.telemetry.track('Manual node exec finished', telemetryPayload)); } else { nodeGraphResult.webhookNodeNames.forEach((name: string) => { const execJson = runData.data.resultData.runData[name]?.[0]?.data?.main?.[0]?.[0] @@ -432,9 +423,7 @@ export class InternalHooks implements IInternalHooksClass { }); promises.push( - this.telemetry.track('Manual workflow exec finished', manualExecEventProperties, { - withPostHog: true, - }), + this.telemetry.track('Manual workflow exec finished', manualExecEventProperties), ); } } @@ -484,7 +473,7 @@ export class InternalHooks implements IInternalHooksClass { user_id_list: userList, }; - return this.telemetry.track('User updated workflow sharing', properties, { withPostHog: true }); + return this.telemetry.track('User updated workflow sharing', properties); } async onN8nStop(): Promise { @@ -1017,7 +1006,7 @@ export class InternalHooks implements IInternalHooksClass { user_id: string; workflow_id: string; }): Promise { - return this.telemetry.track('Workflow first prod success', data, { withPostHog: true }); + return this.telemetry.track('Workflow first prod success', data); } async onFirstWorkflowDataLoad(data: { @@ -1028,7 +1017,7 @@ export class InternalHooks implements IInternalHooksClass { credential_type?: string; credential_id?: string; }): Promise { - return this.telemetry.track('Workflow first data fetched', data, { withPostHog: true }); + return this.telemetry.track('Workflow first data fetched', data); } /** diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 28dd62a14109c..b682e1d8b54dc 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -490,7 +490,7 @@ export class Server extends AbstractServer { const controllers: object[] = [ new EventBusController(), new AuthController({ config, internalHooks, repositories, logger, postHog }), - new OwnerController({ config, internalHooks, repositories, logger }), + new OwnerController({ config, internalHooks, repositories, logger, postHog }), new MeController({ externalHooks, internalHooks, repositories, logger }), new NodeTypesController({ config, nodeTypes }), new PasswordResetController({ diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 432b741eba1f3..5702dc2d35339 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -24,8 +24,9 @@ import { BaseCommand } from './BaseCommand'; import { ExecutionRepository } from '@db/repositories'; import { OwnershipService } from '@/services/ownership.service'; import { generateHostInstanceId } from '@/databases/utils/generators'; -// eslint-disable-next-line import/no-extraneous-dependencies -import { IConfig } from '@oclif/config'; +import type { ICredentialsOverwrite } from '@/Interfaces'; +import { CredentialsOverwrites } from '@/CredentialsOverwrites'; +import { rawBodyReader, bodyParser } from '@/middlewares'; export class Worker extends BaseCommand { static description = '\nStarts a n8n worker'; @@ -46,12 +47,7 @@ export class Worker extends BaseCommand { static jobQueue: JobQueue; - readonly uniqueInstanceId: string; - - constructor(argv: string[], cmdConfig: IConfig) { - super(argv, cmdConfig); - this.uniqueInstanceId = generateHostInstanceId('worker'); - } + readonly uniqueInstanceId = generateHostInstanceId('worker'); /** * Stop n8n in a graceful way. @@ -360,9 +356,40 @@ export class Worker extends BaseCommand { }, ); - server.listen(port, () => { - this.logger.info(`\nn8n worker health check via, port ${port}`); - }); + let presetCredentialsLoaded = false; + const endpointPresetCredentials = config.getEnv('credentials.overwrite.endpoint'); + if (endpointPresetCredentials !== '') { + // POST endpoint to set preset credentials + app.post( + `/${endpointPresetCredentials}`, + rawBodyReader, + bodyParser, + async (req: express.Request, res: express.Response) => { + if (!presetCredentialsLoaded) { + const body = req.body as ICredentialsOverwrite; + + if (req.contentType !== 'application/json') { + ResponseHelper.sendErrorResponse( + res, + new Error( + 'Body must be a valid JSON, make sure the content-type is application/json', + ), + ); + return; + } + + CredentialsOverwrites().setData(body); + presetCredentialsLoaded = true; + ResponseHelper.sendSuccessResponse(res, { success: true }, true, 200); + } else { + ResponseHelper.sendErrorResponse( + res, + new Error('Preset credentials can be set once'), + ); + } + }, + ); + } server.on('error', (error: Error & { code: string }) => { if (error.code === 'EADDRINUSE') { @@ -372,6 +399,10 @@ export class Worker extends BaseCommand { process.exit(1); } }); + + await new Promise((resolve) => server.listen(port, () => resolve())); + await this.externalHooks.run('worker.ready'); + this.logger.info(`\nn8n worker health check via, port ${port}`); } // Make sure that the process does not close diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index d24fd38c16219..567777a39b457 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -762,7 +762,7 @@ export const schema = { externalFrontendHooksUrls: { doc: 'URLs to external frontend hooks files, ; separated', format: String, - default: 'https://public.n8n.cloud/posthog-hooks.js', + default: '', env: 'EXTERNAL_FRONTEND_HOOKS_URLS', }, diff --git a/packages/cli/src/controllers/owner.controller.ts b/packages/cli/src/controllers/owner.controller.ts index 4875e10ec3cca..c09b4c87a5e96 100644 --- a/packages/cli/src/controllers/owner.controller.ts +++ b/packages/cli/src/controllers/owner.controller.ts @@ -6,6 +6,7 @@ import { hashPassword, sanitizeUser, validatePassword, + withFeatureFlags, } from '@/UserManagement/UserManagementHelper'; import { issueCookie } from '@/auth/jwt'; import { Response } from 'express'; @@ -14,6 +15,7 @@ import type { Config } from '@/config'; import { OwnerRequest } from '@/requests'; import type { IDatabaseCollections, IInternalHooksClass } from '@/Interfaces'; import type { SettingsRepository, UserRepository } from '@db/repositories'; +import type { PostHogClient } from '@/posthog'; @Authorized(['global', 'owner']) @RestController('/owner') @@ -28,22 +30,27 @@ export class OwnerController { private readonly settingsRepository: SettingsRepository; + private readonly postHog?: PostHogClient; + constructor({ config, logger, internalHooks, repositories, + postHog, }: { config: Config; logger: ILogger; internalHooks: IInternalHooksClass; repositories: Pick; + postHog?: PostHogClient; }) { this.config = config; this.logger = logger; this.internalHooks = internalHooks; this.userRepository = repositories.User; this.settingsRepository = repositories.Settings; + this.postHog = postHog; } /** @@ -122,7 +129,7 @@ export class OwnerController { void this.internalHooks.onInstanceOwnerSetup({ user_id: userId }); - return sanitizeUser(owner); + return withFeatureFlags(this.postHog, sanitizeUser(owner)); } @Post('/dismiss-banner') diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index dfe295cea93ee..8050365a57bfb 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -1,6 +1,6 @@ import type express from 'express'; import type { - Banners, + BannerName, IConnections, ICredentialDataDecryptedObject, ICredentialNodeAccess, @@ -216,7 +216,7 @@ export interface UserSetupPayload { export declare namespace OwnerRequest { type Post = AuthenticatedRequest<{}, {}, UserSetupPayload, {}>; - type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: Banners }>, {}>; + type DismissBanner = AuthenticatedRequest<{}, {}, Partial<{ bannerName: BannerName }>, {}>; } // ---------------------------------- diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 7e78ca996a5b5..75b87a2c6b801 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -95,15 +95,11 @@ export class Telemetry { return sum > 0; }) .map(async (workflowId) => { - const promise = this.track( - 'Workflow execution count', - { - event_version: '2', - workflow_id: workflowId, - ...this.executionCountsBuffer[workflowId], - }, - { withPostHog: true }, - ); + const promise = this.track('Workflow execution count', { + event_version: '2', + workflow_id: workflowId, + ...this.executionCountsBuffer[workflowId], + }); return promise; }); diff --git a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue index 4b92dd4d74780..f555db6eb1df2 100644 --- a/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue +++ b/packages/design-system/src/components/N8nActionToggle/ActionToggle.vue @@ -21,6 +21,7 @@ :key="action.value" :command="action.value" :disabled="action.disabled" + :data-test-id="`action-${action.value}`" > {{ action.label }}
diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue index 38c340380fe94..f029f16722a9f 100644 --- a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue +++ b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue @@ -36,7 +36,7 @@
diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index e8b4ea949fbd0..503f0a1a0a9e7 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -39,7 +39,7 @@ import BannerStack from '@/components/banners/BannerStack.vue'; import Modals from '@/components/Modals.vue'; import LoadingView from '@/views/LoadingView.vue'; import Telemetry from '@/components/Telemetry.vue'; -import { CLOUD_TRIAL_CHECK_INTERVAL, HIRING_BANNER, LOCAL_STORAGE_THEME, VIEWS } from '@/constants'; +import { HIRING_BANNER, LOCAL_STORAGE_THEME, VIEWS } from '@/constants'; import { userHelpers } from '@/mixins/userHelpers'; import { loadLanguage } from '@/plugins/i18n'; @@ -143,6 +143,12 @@ export default defineComponent({ console.log(HIRING_BANNER); } }, + async initBanners() { + return this.uiStore.initBanners(); + }, + async checkForCloudPlanData() { + return this.cloudPlanStore.checkForCloudPlanData(); + }, async initialize(): Promise { await this.initSettings(); await Promise.all([this.loginWithCookie(), this.initTemplates()]); @@ -209,35 +215,6 @@ export default defineComponent({ window.document.body.classList.add(`theme-${theme}`); } }, - async checkForCloudPlanData(): Promise { - try { - await this.cloudPlanStore.getOwnerCurrentPlan(); - if (!this.cloudPlanStore.userIsTrialing) return; - await this.cloudPlanStore.getInstanceCurrentUsage(); - this.startPollingInstanceUsageData(); - } catch {} - }, - startPollingInstanceUsageData() { - const interval = setInterval(async () => { - try { - await this.cloudPlanStore.getInstanceCurrentUsage(); - if (this.cloudPlanStore.trialExpired || this.cloudPlanStore.allExecutionsUsed) { - clearTimeout(interval); - return; - } - } catch {} - }, CLOUD_TRIAL_CHECK_INTERVAL); - }, - async initBanners(): Promise { - if (this.cloudPlanStore.userIsTrialing) { - await this.uiStore.dismissBanner('V1', 'temporary'); - if (this.cloudPlanStore.trialExpired) { - this.uiStore.showBanner('TRIAL_OVER'); - } else { - this.uiStore.showBanner('TRIAL'); - } - } - }, async postAuthenticate() { if (this.postAuthenticateDone) { return; @@ -262,9 +239,7 @@ export default defineComponent({ await this.redirectIfNecessary(); void this.checkForNewVersions(); await this.checkForCloudPlanData(); - await this.initBanners(); - - void this.checkForCloudPlanData(); + void this.initBanners(); void this.postAuthenticate(); this.loading = false; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index d38bef1f124bc..02814e992ffba 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -34,7 +34,7 @@ import type { IUserManagementSettings, WorkflowSettings, IUserSettings, - Banners, + BannerName, } from 'n8n-workflow'; import type { SignInType } from './constants'; import type { @@ -78,6 +78,12 @@ declare global { reset?(resetDeviceId?: boolean): void; onFeatureFlags?(callback: (keys: string[], map: FeatureFlags) => void): void; reloadFeatureFlags?(): void; + capture?(event: string, properties: IDataObject): void; + register?(metadata: IDataObject): void; + people?: { + set?(metadata: IDataObject): void; + }; + debug?(): void; }; analytics?: { track(event: string, proeprties?: ITelemetryTrackProperties): void; @@ -1074,7 +1080,7 @@ export interface UIState { addFirstStepOnLoad: boolean; executionSidebarAutoRefresh: boolean; bannersHeight: number; - banners: { [key in Banners]: { dismissed: boolean; type?: 'temporary' | 'permanent' } }; + banners: { [key in BannerName]: { dismissed: boolean; type?: 'temporary' | 'permanent' } }; } export type IFakeDoor = { diff --git a/packages/editor-ui/src/api/ui.ts b/packages/editor-ui/src/api/ui.ts index 25683ad46e3fd..6f4be13b07a2f 100644 --- a/packages/editor-ui/src/api/ui.ts +++ b/packages/editor-ui/src/api/ui.ts @@ -1,10 +1,10 @@ import type { IRestApiContext } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; -import type { Banners } from 'n8n-workflow'; +import type { BannerName } from 'n8n-workflow'; export async function dismissBannerPermanently( context: IRestApiContext, - data: { bannerName: Banners; dismissedBanners: string[] }, + data: { bannerName: BannerName; dismissedBanners: string[] }, ): Promise { return makeRestApiRequest(context, 'POST', '/owner/dismiss-banner', { banner: data.bannerName }); } diff --git a/packages/editor-ui/src/api/users.ts b/packages/editor-ui/src/api/users.ts index 74b717f280905..6f6d9bd86797d 100644 --- a/packages/editor-ui/src/api/users.ts +++ b/packages/editor-ui/src/api/users.ts @@ -28,7 +28,7 @@ export async function logout(context: IRestApiContext): Promise { export async function setupOwner( context: IRestApiContext, params: { firstName: string; lastName: string; email: string; password: string }, -): Promise { +): Promise { return makeRestApiRequest(context, 'POST', '/owner/setup', params as unknown as IDataObject); } diff --git a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue index 14401cd10fb69..0727a4401fced 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue +++ b/packages/editor-ui/src/components/CodeNodeEditor/CodeNodeEditor.vue @@ -42,8 +42,9 @@ + + diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 3cc1d63ccd200..e4641639a5700 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -115,6 +115,7 @@ "auth.signup.setupYourAccount": "Set up your account", "auth.signup.setupYourAccountError": "Problem setting up your account", "auth.signup.tokenValidationError": "Issue validating invite token", + "banners.nonProductionLicense.message": "This n8n instance is not licensed for production purposes!", "banners.trial.message": "1 day left in your n8n trial | {count} days left in your n8n trial", "banners.trialOver.message": "Your trial is over. Upgrade now to keep automating.", "banners.v1.message": "n8n has been updated to version 1, introducing some breaking changes. Please consult the migration guide for more information.", diff --git a/packages/editor-ui/src/plugins/telemetry/index.ts b/packages/editor-ui/src/plugins/telemetry/index.ts index e5fec616c28a7..054a8a7f2b0df 100644 --- a/packages/editor-ui/src/plugins/telemetry/index.ts +++ b/packages/editor-ui/src/plugins/telemetry/index.ts @@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useTelemetryStore } from '@/stores/telemetry.store'; import { SLACK_NODE_TYPE } from '@/constants'; +import { usePostHog } from '@/stores/posthog.store'; export class Telemetry { constructor( @@ -36,7 +37,7 @@ export class Telemetry { versionCli: string; }, ) { - if (!telemetrySettings.enabled || !telemetrySettings.config || this.rudderStack) return; + if (!telemetrySettings.enabled || !telemetrySettings.config) return; const { config: { key, url }, @@ -72,7 +73,11 @@ export class Telemetry { } } - track(event: string, properties?: ITelemetryTrackProperties) { + track( + event: string, + properties?: ITelemetryTrackProperties, + { withPostHog } = { withPostHog: false }, + ) { if (!this.rudderStack) return; const updatedProperties = { @@ -81,6 +86,10 @@ export class Telemetry { }; this.rudderStack.track(event, updatedProperties); + + if (withPostHog) { + usePostHog().capture(event, updatedProperties); + } } page(route: Route) { @@ -119,7 +128,7 @@ export class Telemetry { properties.session_id = useRootStore().sessionId; switch (event) { case 'askAi.generationFinished': - this.track('Ai code generation finished', properties); + this.track('Ai code generation finished', properties, { withPostHog: true }); case 'ask.generationClicked': this.track('User clicked on generate code button', properties); default: @@ -189,7 +198,7 @@ export class Telemetry { this.track('User viewed node category', properties); break; case 'nodeView.addNodeButton': - this.track('User added node to workflow canvas', properties); + this.track('User added node to workflow canvas', properties, { withPostHog: true }); break; case 'nodeView.addSticky': this.track('User inserted workflow note', properties); diff --git a/packages/editor-ui/src/stores/cloudPlan.store.ts b/packages/editor-ui/src/stores/cloudPlan.store.ts index 38194ee15a377..cc6e85bf7e539 100644 --- a/packages/editor-ui/src/stores/cloudPlan.store.ts +++ b/packages/editor-ui/src/stores/cloudPlan.store.ts @@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useUsersStore } from '@/stores/users.store'; import { getCurrentPlan, getCurrentUsage } from '@/api/cloudPlans'; import { DateTime } from 'luxon'; +import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/constants'; const DEFAULT_STATE: CloudPlanState = { data: null, @@ -28,6 +29,11 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => { state.usage = data; }; + const reset = () => { + state.data = null; + state.usage = null; + }; + const userIsTrialing = computed(() => state.data?.metadata?.group === 'trial'); const currentPlanData = computed(() => state.data); @@ -89,6 +95,27 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => { return Math.ceil(differenceInDays); }); + const startPollingInstanceUsageData = () => { + const interval = setInterval(async () => { + try { + await getInstanceCurrentUsage(); + if (trialExpired.value || allExecutionsUsed.value) { + clearTimeout(interval); + return; + } + } catch {} + }, CLOUD_TRIAL_CHECK_INTERVAL); + }; + + const checkForCloudPlanData = async (): Promise => { + try { + await getOwnerCurrentPlan(); + if (!userIsTrialing.value) return; + await getInstanceCurrentUsage(); + startPollingInstanceUsageData(); + } catch {} + }; + return { state, getOwnerCurrentPlan, @@ -100,5 +127,7 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => { currentUsageData, trialExpired, allExecutionsUsed, + reset, + checkForCloudPlanData, }; }); diff --git a/packages/editor-ui/src/stores/posthog.store.ts b/packages/editor-ui/src/stores/posthog.store.ts index 5bc219f0f1edf..ffe95ed53a3ed 100644 --- a/packages/editor-ui/src/stores/posthog.store.ts +++ b/packages/editor-ui/src/stores/posthog.store.ts @@ -4,7 +4,7 @@ import { defineStore } from 'pinia'; import { useUsersStore } from '@/stores/users.store'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useSettingsStore } from '@/stores/settings.store'; -import type { FeatureFlags } from 'n8n-workflow'; +import type { FeatureFlags, IDataObject } from 'n8n-workflow'; import { EXPERIMENTS_TO_TRACK, LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/constants'; import { useTelemetryStore } from './telemetry.store'; import { debounce } from 'lodash-es'; @@ -161,10 +161,29 @@ export const usePostHog = defineStore('posthog', () => { trackedDemoExp.value[name] = variant; }; + const capture = (event: string, properties: IDataObject) => { + if (typeof window.posthog?.capture === 'function') { + window.posthog.capture(event, properties); + } + }; + + const setMetadata = (metadata: IDataObject, target: 'user' | 'events') => { + if (typeof window.posthog?.people?.set !== 'function') return; + if (typeof window.posthog?.register !== 'function') return; + + if (target === 'user') { + window.posthog?.people?.set(metadata); + } else if (target === 'events') { + window.posthog?.register(metadata); + } + }; + return { init, isVariantEnabled, getVariant, reset, + capture, + setMetadata, }; }); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 5fbc12ceeb41f..9b8dcd758031b 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -198,6 +198,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { this.saml.loginEnabled = settings.sso.saml.loginEnabled; this.saml.loginLabel = settings.sso.saml.loginLabel; } + if (settings.enterprise?.showNonProdBanner) { + useUIStore().banners.NON_PRODUCTION_LICENSE.dismissed = false; + } }, async getSettings(): Promise { const rootStore = useRootStore(); diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index bf1724d637b55..f3fe6c6b4a85b 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -49,14 +49,14 @@ import { defineStore } from 'pinia'; import { useRootStore } from './n8nRoot.store'; import { getCurlToJson } from '@/api/curlHelper'; import { useWorkflowsStore } from './workflows.store'; -import { useSettingsStore } from './settings.store'; -import { useCloudPlanStore } from './cloudPlan.store'; +import { useSettingsStore } from '@/stores/settings.store'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import type { BaseTextKey } from '@/plugins/i18n'; import { i18n as locale } from '@/plugins/i18n'; import { useTelemetryStore } from '@/stores/telemetry.store'; import { getStyleTokenValue } from '@/utils/htmlUtils'; import { dismissBannerPermanently } from '@/api/ui'; -import type { Banners } from 'n8n-workflow'; +import type { BannerName } from 'n8n-workflow'; export const useUIStore = defineStore(STORES.UI, { state: (): UIState => ({ @@ -176,6 +176,7 @@ export const useUIStore = defineStore(STORES.UI, { V1: { dismissed: true }, TRIAL: { dismissed: true }, TRIAL_OVER: { dismissed: true }, + NON_PRODUCTION_LICENSE: { dismissed: true }, }, bannersHeight: 0, }), @@ -333,12 +334,6 @@ export const useUIStore = defineStore(STORES.UI, { }, }, actions: { - setBanners(banners: UIState['banners']): void { - this.banners = { - ...this.banners, - ...banners, - }; - }, setMode(name: keyof Modals, mode: string): void { this.modals[name] = { ...this.modals[name], @@ -541,7 +536,7 @@ export const useUIStore = defineStore(STORES.UI, { } }, async dismissBanner( - name: Banners, + name: BannerName, type: 'temporary' | 'permanent' = 'temporary', ): Promise { if (type === 'permanent') { @@ -556,11 +551,29 @@ export const useUIStore = defineStore(STORES.UI, { this.banners[name].dismissed = true; this.banners[name].type = 'temporary'; }, - showBanner(name: Banners): void { + showBanner(name: BannerName): void { this.banners[name].dismissed = false; }, updateBannersHeight(newHeight: number): void { this.bannersHeight = newHeight; }, + async initBanners(): Promise { + const cloudPlanStore = useCloudPlanStore(); + if (cloudPlanStore.userIsTrialing) { + await this.dismissBanner('V1', 'temporary'); + if (cloudPlanStore.trialExpired) { + this.showBanner('TRIAL_OVER'); + } else { + this.showBanner('TRIAL'); + } + } + }, + async dismissAllBanners() { + return Promise.all([ + this.dismissBanner('TRIAL', 'temporary'), + this.dismissBanner('TRIAL_OVER', 'temporary'), + this.dismissBanner('V1', 'temporary'), + ]); + }, }, }); diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 74e3672969171..6b9d551e79531 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -37,6 +37,7 @@ import { useRootStore } from './n8nRoot.store'; import { usePostHog } from './posthog.store'; import { useSettingsStore } from './settings.store'; import { useUIStore } from './ui.store'; +import { useCloudPlanStore } from './cloudPlan.store'; const isDefaultUser = (user: IUserResponse | null) => Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); @@ -182,7 +183,9 @@ export const useUsersStore = defineStore(STORES.USERS, { const rootStore = useRootStore(); await logout(rootStore.getRestApiContext); this.currentUserId = null; + useCloudPlanStore().reset(); usePostHog().reset(); + await useUIStore().dismissAllBanners(); }, async createOwner(params: { firstName: string; @@ -197,6 +200,7 @@ export const useUsersStore = defineStore(STORES.USERS, { this.addUsers([user]); this.currentUserId = user.id; settingsStore.stopShowingSetupPage(); + usePostHog().init(user.featureFlags); } }, async validateSignupToken(params: { @@ -218,9 +222,8 @@ export const useUsersStore = defineStore(STORES.USERS, { if (user) { this.addUsers([user]); this.currentUserId = user.id; + usePostHog().init(user.featureFlags); } - - usePostHog().init(user.featureFlags); }, async sendForgotPasswordEmail(params: { email: string }): Promise { const rootStore = useRootStore(); diff --git a/packages/editor-ui/src/views/SigninView.vue b/packages/editor-ui/src/views/SigninView.vue index ca03634f1d60f..4071a6c486658 100644 --- a/packages/editor-ui/src/views/SigninView.vue +++ b/packages/editor-ui/src/views/SigninView.vue @@ -18,6 +18,7 @@ import { VIEWS } from '@/constants'; import { mapStores } from 'pinia'; import { useUsersStore } from '@/stores/users.store'; import { useSettingsStore } from '@/stores/settings.store'; +import { useCloudPlanStore, useUIStore } from '@/stores'; export default defineComponent({ name: 'SigninView', @@ -36,7 +37,7 @@ export default defineComponent({ }; }, computed: { - ...mapStores(useUsersStore, useSettingsStore), + ...mapStores(useUsersStore, useSettingsStore, useUIStore, useCloudPlanStore), }, mounted() { let emailLabel = this.$locale.baseText('auth.email'); @@ -87,6 +88,8 @@ export default defineComponent({ try { this.loading = true; await this.usersStore.loginWithCreds(values as { email: string; password: string }); + await this.cloudPlanStore.checkForCloudPlanData(); + await this.uiStore.initBanners(); this.clearAllStickyNotifications(); this.loading = false; diff --git a/packages/editor-ui/src/views/TemplatesCollectionView.vue b/packages/editor-ui/src/views/TemplatesCollectionView.vue index 45acfe26260de..ab2b617cfe942 100644 --- a/packages/editor-ui/src/views/TemplatesCollectionView.vue +++ b/packages/editor-ui/src/views/TemplatesCollectionView.vue @@ -68,6 +68,7 @@ import type { import { setPageTitle } from '@/utils'; import { VIEWS } from '@/constants'; import { useTemplatesStore } from '@/stores/templates.store'; +import { usePostHog } from '@/stores/posthog.store'; export default defineComponent({ name: 'TemplatesCollectionView', @@ -78,7 +79,7 @@ export default defineComponent({ TemplatesView, }, computed: { - ...mapStores(useTemplatesStore), + ...mapStores(useTemplatesStore, usePostHog), collection(): null | ITemplatesCollectionFull { return this.templatesStore.getCollectionById(this.collectionId); }, @@ -122,8 +123,9 @@ export default defineComponent({ source: 'collection', }; void this.$externalHooks().run('templatesCollectionView.onUseWorkflow', telemetryPayload); - this.$telemetry.track('User inserted workflow template', telemetryPayload); - + this.$telemetry.track('User inserted workflow template', telemetryPayload, { + withPostHog: true, + }); this.navigateTo(event, VIEWS.TEMPLATE_IMPORT, id); }, navigateTo(e: MouseEvent, page: string, id: string) { diff --git a/packages/editor-ui/src/views/TemplatesWorkflowView.vue b/packages/editor-ui/src/views/TemplatesWorkflowView.vue index cb45aa60e720b..d2761f36e878b 100644 --- a/packages/editor-ui/src/views/TemplatesWorkflowView.vue +++ b/packages/editor-ui/src/views/TemplatesWorkflowView.vue @@ -67,6 +67,7 @@ import { workflowHelpers } from '@/mixins/workflowHelpers'; import { setPageTitle } from '@/utils'; import { VIEWS } from '@/constants'; import { useTemplatesStore } from '@/stores/templates.store'; +import { usePostHog } from '@/stores/posthog.store'; export default defineComponent({ name: 'TemplatesWorkflowView', @@ -77,7 +78,7 @@ export default defineComponent({ WorkflowPreview, }, computed: { - ...mapStores(useTemplatesStore), + ...mapStores(useTemplatesStore, usePostHog), template(): ITemplatesWorkflow | ITemplatesWorkflowFull { return this.templatesStore.getTemplateById(this.templateId); }, @@ -101,8 +102,9 @@ export default defineComponent({ }; void this.$externalHooks().run('templatesWorkflowView.openWorkflow', telemetryPayload); - this.$telemetry.track('User inserted workflow template', telemetryPayload); - + this.$telemetry.track('User inserted workflow template', telemetryPayload, { + withPostHog: true, + }); if (e.metaKey || e.ctrlKey) { const route = this.$router.resolve({ name: VIEWS.TEMPLATE_IMPORT, params: { id } }); window.open(route.href, '_blank'); diff --git a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts index fff4bb6e697af..976f65f894434 100644 --- a/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts +++ b/packages/nodes-base/nodes/Salesforce/Salesforce.node.ts @@ -2213,8 +2213,8 @@ export class Salesforce implements INodeType { if (updateFields.phone !== undefined) { body.Phone = updateFields.phone as string; } - if (updateFields.owner !== undefined) { - body.OwnerId = updateFields.owner as string; + if (updateFields.ownerId !== undefined) { + body.OwnerId = updateFields.ownerId as string; } if (updateFields.sicDesc !== undefined) { body.SicDesc = updateFields.sicDesc as string; diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 00fc5ecab503e..a1a403ff0595b 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2203,4 +2203,4 @@ export interface IN8nUISettings { }; } -export type Banners = 'V1' | 'TRIAL_OVER' | 'TRIAL'; +export type BannerName = 'V1' | 'TRIAL_OVER' | 'TRIAL' | 'NON_PRODUCTION_LICENSE'; diff --git a/packages/workflow/src/Workflow.ts b/packages/workflow/src/Workflow.ts index 02c104d2dbee8..267780536cb1f 100644 --- a/packages/workflow/src/Workflow.ts +++ b/packages/workflow/src/Workflow.ts @@ -972,12 +972,52 @@ export class Workflow { return this.__getStartNode(Object.keys(this.nodes)); } - /** - * Executes the Webhooks method of the node - * - * @param {WebhookSetupMethodNames} method The name of the method to execute - */ - async runWebhookMethod( + async createWebhookIfNotExists( + webhookData: IWebhookData, + nodeExecuteFunctions: INodeExecuteFunctions, + mode: WorkflowExecuteMode, + activation: WorkflowActivateMode, + isTest?: boolean, + ): Promise { + const webhookExists = await this.runWebhookMethod( + 'checkExists', + webhookData, + nodeExecuteFunctions, + mode, + activation, + isTest, + ); + if (!webhookExists) { + // If webhook does not exist yet create it + await this.runWebhookMethod( + 'create', + webhookData, + nodeExecuteFunctions, + mode, + activation, + isTest, + ); + } + } + + async deleteWebhook( + webhookData: IWebhookData, + nodeExecuteFunctions: INodeExecuteFunctions, + mode: WorkflowExecuteMode, + activation: WorkflowActivateMode, + isTest?: boolean, + ) { + await this.runWebhookMethod( + 'delete', + webhookData, + nodeExecuteFunctions, + mode, + activation, + isTest, + ); + } + + private async runWebhookMethod( method: WebhookSetupMethodNames, webhookData: IWebhookData, nodeExecuteFunctions: INodeExecuteFunctions,