From 46d9b6004984bec75687a1b5ffdb3c28868eedb6 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Tue, 15 Apr 2025 09:37:54 +0200 Subject: [PATCH] feat(editor): Fix paywall for dashboard disabled licences (#14617) --- .../modules/insights/insights.controller.ts | 4 +- .../integration/insights/insights.api.test.ts | 55 ++++++++------ packages/frontend/editor-ui/src/Interface.ts | 6 +- .../insights/components/InsightsDashboard.vue | 75 ++++++++++--------- .../insights/components/InsightsPaywall.vue | 28 +++++-- .../src/features/insights/insights.store.ts | 2 + .../src/plugins/i18n/locales/en.json | 3 + 7 files changed, 108 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 181a871b98..7fb59cf27b 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -1,7 +1,7 @@ import { ListInsightsWorkflowQueryDto } from '@n8n/api-types'; import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } from '@n8n/api-types'; -import { Get, GlobalScope, Query, RestController } from '@/decorators'; +import { Get, GlobalScope, Licensed, Query, RestController } from '@/decorators'; import { paginationListQueryMiddleware } from '@/middlewares/list-query/pagination'; import { sortByQueryMiddleware } from '@/middlewares/list-query/sort-by'; import { AuthenticatedRequest } from '@/requests'; @@ -24,6 +24,7 @@ export class InsightsController { @Get('/by-workflow', { middlewares: [paginationListQueryMiddleware, sortByQueryMiddleware] }) @GlobalScope('insights:list') + @Licensed('feat:insights:viewDashboard') async getInsightsByWorkflow( _req: AuthenticatedRequest, _res: Response, @@ -39,6 +40,7 @@ export class InsightsController { @Get('/by-time') @GlobalScope('insights:list') + @Licensed('feat:insights:viewDashboard') async getInsightsByTime(): Promise { return await this.insightsService.getInsightsByTime({ maxAgeInDays: this.maxAgeInDaysFilteredInsights, diff --git a/packages/cli/test/integration/insights/insights.api.test.ts b/packages/cli/test/integration/insights/insights.api.test.ts index c8b633d438..b351edca4a 100644 --- a/packages/cli/test/integration/insights/insights.api.test.ts +++ b/packages/cli/test/integration/insights/insights.api.test.ts @@ -1,4 +1,3 @@ -import type { User } from '@/databases/entities/user'; import { Telemetry } from '@/telemetry'; import { mockInstance } from '@test/mocking'; @@ -6,44 +5,58 @@ import { createUser } from '../shared/db/users'; import type { SuperAgentTest } from '../shared/types'; import * as utils from '../shared/utils'; -let authOwnerAgent: SuperAgentTest; -let owner: User; -let admin: User; -let member: User; mockInstance(Telemetry); let agents: Record = {}; - const testServer = utils.setupTestServer({ endpointGroups: ['insights', 'license', 'auth'], - enabledFeatures: [], + enabledFeatures: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'], }); beforeAll(async () => { - owner = await createUser({ role: 'global:owner' }); - admin = await createUser({ role: 'global:admin' }); - member = await createUser({ role: 'global:member' }); - authOwnerAgent = testServer.authAgentFor(owner); - agents.owner = authOwnerAgent; + const owner = await createUser({ role: 'global:owner' }); + const admin = await createUser({ role: 'global:admin' }); + const member = await createUser({ role: 'global:member' }); + agents.owner = testServer.authAgentFor(owner); agents.admin = testServer.authAgentFor(admin); agents.member = testServer.authAgentFor(member); }); -describe('GET /insights routes work for owner and admins', () => { - test.each(['owner', 'member', 'admin'])( +describe('GET /insights routes work for owner and admins for server with dashboard license', () => { + test.each(['owner', 'admin', 'member'])( 'Call should work and return empty summary for user %s', async (agentName: string) => { const authAgent = agents[agentName]; - await authAgent.get('/insights/summary').expect(agentName === 'member' ? 403 : 200); - await authAgent.get('/insights/by-time').expect(agentName === 'member' ? 403 : 200); - await authAgent.get('/insights/by-workflow').expect(agentName === 'member' ? 403 : 200); + await authAgent.get('/insights/summary').expect(agentName.includes('member') ? 403 : 200); + await authAgent.get('/insights/by-time').expect(agentName.includes('member') ? 403 : 200); + await authAgent.get('/insights/by-workflow').expect(agentName.includes('member') ? 403 : 200); }, ); }); -describe('GET /insights/by-worklow', () => { +describe('GET /insights routes return 403 for dashboard routes when summary license only', () => { + beforeAll(() => { + testServer.license.setDefaults({ features: ['feat:insights:viewSummary'] }); + }); + test.each(['owner', 'admin', 'member'])( + 'Call should work and return empty summary for user %s', + async (agentName: string) => { + const authAgent = agents[agentName]; + await authAgent.get('/insights/summary').expect(agentName.includes('member') ? 403 : 200); + await authAgent.get('/insights/by-time').expect(403); + await authAgent.get('/insights/by-workflow').expect(403); + }, + ); +}); + +describe('GET /insights/by-workflow', () => { + beforeAll(() => { + testServer.license.setDefaults({ + features: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'], + }); + }); test('Call should work with valid query parameters', async () => { - await authOwnerAgent + await agents.owner .get('/insights/by-workflow') .query({ skip: '10', take: '20', sortBy: 'total:desc' }) .expect(200); @@ -61,12 +74,12 @@ describe('GET /insights/by-worklow', () => { ])( 'Call should return internal server error with invalid pagination query parameters', async (queryParams) => { - await authOwnerAgent.get('/insights/by-workflow').query(queryParams).expect(500); + await agents.owner.get('/insights/by-workflow').query(queryParams).expect(500); }, ); test('Call should return bad request with invalid sortby query parameters', async () => { - await authOwnerAgent + await agents.owner .get('/insights/by-workflow') .query({ skip: '1', diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index e713d48b62..71eab28571 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -1471,7 +1471,8 @@ export type CloudUpdateLinkSourceType = | 'worker-view' | 'external-secrets' | 'rbac' - | 'debug'; + | 'debug' + | 'insights'; export type UTMCampaign = | 'upgrade-custom-data-filter' @@ -1494,7 +1495,8 @@ export type UTMCampaign = | 'upgrade-worker-view' | 'upgrade-external-secrets' | 'upgrade-rbac' - | 'upgrade-debug'; + | 'upgrade-debug' + | 'upgrade-insights'; export type N8nBanners = { [key in BannerName]: { diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue index 1c1082a0f2..f5b5b0d8b9 100644 --- a/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.vue @@ -80,8 +80,10 @@ watch( void insightsStore.summary.execute(); } - void insightsStore.charts.execute(); - fetchPaginatedTableData({ sortBy: sortTableBy.value }); + if (insightsStore.isDashboardEnabled) { + void insightsStore.charts.execute(); + fetchPaginatedTableData({ sortBy: sortTableBy.value }); + } }, { immediate: true, @@ -102,41 +104,46 @@ watch( :loading="insightsStore.summary.isLoading" :class="$style.insightsBanner" /> -
-
-
- - - - {{ i18n.baseText('insights.chart.loading') }} +
+ +
+
+
+ + + + {{ i18n.baseText('insights.chart.loading') }} +
+ +
+
+
- -
-
-
-
diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue b/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue index 9f6ec2c8e6..0fdc21e330 100644 --- a/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsPaywall.vue @@ -1,14 +1,27 @@ - + @@ -17,6 +30,7 @@ display: flex; flex-direction: column; height: 100%; + padding: 6rem 0; align-items: center; max-width: 360px; margin: 0 auto; diff --git a/packages/frontend/editor-ui/src/features/insights/insights.store.ts b/packages/frontend/editor-ui/src/features/insights/insights.store.ts index cf45c7fbd3..2c1a812418 100644 --- a/packages/frontend/editor-ui/src/features/insights/insights.store.ts +++ b/packages/frontend/editor-ui/src/features/insights/insights.store.ts @@ -19,6 +19,7 @@ export const useInsightsStore = defineStore('insights', () => { ); const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled); + const isDashboardEnabled = computed(() => settingsStore.settings.insights.dashboard); const isSummaryEnabled = computed( () => globalInsightsPermissions.value.list && isInsightsEnabled.value, @@ -56,6 +57,7 @@ export const useInsightsStore = defineStore('insights', () => { globalInsightsPermissions, isInsightsEnabled, isSummaryEnabled, + isDashboardEnabled, summary, charts, table, diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index b8422e8444..afc0e40d48 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -3092,6 +3092,9 @@ "insights.dashboard.table.projectName": "Project name", "insights.dashboard.table.estimate": "Estimate", "insights.dashboard.title": "Insights", + "insights.dashboard.paywall.cta": "Upgrade", + "insights.dashboard.paywall.title": "Upgrade to access more detailed insights", + "insights.dashboard.paywall.description": "Gain access to more granular, per-workflow insights and visual breakdown of production executions over different time periods.", "insights.banner.title.timeSaved.tooltip": "Total time saved calculated from your estimated time savings per execution across all workflows", "insights.banner.failureRate.deviation.tooltip": "Percentage point change from previous period", "insights.chart.failed": "Failed",