feat(editor): Fix paywall for dashboard disabled licences (#14617)

This commit is contained in:
Guillaume Jacquart
2025-04-15 09:37:54 +02:00
committed by GitHub
parent 390c508946
commit 46d9b60049
7 changed files with 108 additions and 65 deletions

View File

@@ -1,7 +1,7 @@
import { ListInsightsWorkflowQueryDto } from '@n8n/api-types'; import { ListInsightsWorkflowQueryDto } from '@n8n/api-types';
import type { InsightsSummary, InsightsByTime, InsightsByWorkflow } 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 { paginationListQueryMiddleware } from '@/middlewares/list-query/pagination';
import { sortByQueryMiddleware } from '@/middlewares/list-query/sort-by'; import { sortByQueryMiddleware } from '@/middlewares/list-query/sort-by';
import { AuthenticatedRequest } from '@/requests'; import { AuthenticatedRequest } from '@/requests';
@@ -24,6 +24,7 @@ export class InsightsController {
@Get('/by-workflow', { middlewares: [paginationListQueryMiddleware, sortByQueryMiddleware] }) @Get('/by-workflow', { middlewares: [paginationListQueryMiddleware, sortByQueryMiddleware] })
@GlobalScope('insights:list') @GlobalScope('insights:list')
@Licensed('feat:insights:viewDashboard')
async getInsightsByWorkflow( async getInsightsByWorkflow(
_req: AuthenticatedRequest, _req: AuthenticatedRequest,
_res: Response, _res: Response,
@@ -39,6 +40,7 @@ export class InsightsController {
@Get('/by-time') @Get('/by-time')
@GlobalScope('insights:list') @GlobalScope('insights:list')
@Licensed('feat:insights:viewDashboard')
async getInsightsByTime(): Promise<InsightsByTime[]> { async getInsightsByTime(): Promise<InsightsByTime[]> {
return await this.insightsService.getInsightsByTime({ return await this.insightsService.getInsightsByTime({
maxAgeInDays: this.maxAgeInDaysFilteredInsights, maxAgeInDays: this.maxAgeInDaysFilteredInsights,

View File

@@ -1,4 +1,3 @@
import type { User } from '@/databases/entities/user';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
@@ -6,44 +5,58 @@ import { createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types'; import type { SuperAgentTest } from '../shared/types';
import * as utils from '../shared/utils'; import * as utils from '../shared/utils';
let authOwnerAgent: SuperAgentTest;
let owner: User;
let admin: User;
let member: User;
mockInstance(Telemetry); mockInstance(Telemetry);
let agents: Record<string, SuperAgentTest> = {}; let agents: Record<string, SuperAgentTest> = {};
const testServer = utils.setupTestServer({ const testServer = utils.setupTestServer({
endpointGroups: ['insights', 'license', 'auth'], endpointGroups: ['insights', 'license', 'auth'],
enabledFeatures: [], enabledFeatures: ['feat:insights:viewSummary', 'feat:insights:viewDashboard'],
}); });
beforeAll(async () => { beforeAll(async () => {
owner = await createUser({ role: 'global:owner' }); const owner = await createUser({ role: 'global:owner' });
admin = await createUser({ role: 'global:admin' }); const admin = await createUser({ role: 'global:admin' });
member = await createUser({ role: 'global:member' }); const member = await createUser({ role: 'global:member' });
authOwnerAgent = testServer.authAgentFor(owner); agents.owner = testServer.authAgentFor(owner);
agents.owner = authOwnerAgent;
agents.admin = testServer.authAgentFor(admin); agents.admin = testServer.authAgentFor(admin);
agents.member = testServer.authAgentFor(member); agents.member = testServer.authAgentFor(member);
}); });
describe('GET /insights routes work for owner and admins', () => { describe('GET /insights routes work for owner and admins for server with dashboard license', () => {
test.each(['owner', 'member', 'admin'])( test.each(['owner', 'admin', 'member'])(
'Call should work and return empty summary for user %s', 'Call should work and return empty summary for user %s',
async (agentName: string) => { async (agentName: string) => {
const authAgent = agents[agentName]; const authAgent = agents[agentName];
await authAgent.get('/insights/summary').expect(agentName === 'member' ? 403 : 200); await authAgent.get('/insights/summary').expect(agentName.includes('member') ? 403 : 200);
await authAgent.get('/insights/by-time').expect(agentName === 'member' ? 403 : 200); await authAgent.get('/insights/by-time').expect(agentName.includes('member') ? 403 : 200);
await authAgent.get('/insights/by-workflow').expect(agentName === '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 () => { test('Call should work with valid query parameters', async () => {
await authOwnerAgent await agents.owner
.get('/insights/by-workflow') .get('/insights/by-workflow')
.query({ skip: '10', take: '20', sortBy: 'total:desc' }) .query({ skip: '10', take: '20', sortBy: 'total:desc' })
.expect(200); .expect(200);
@@ -61,12 +74,12 @@ describe('GET /insights/by-worklow', () => {
])( ])(
'Call should return internal server error with invalid pagination query parameters', 'Call should return internal server error with invalid pagination query parameters',
async (queryParams) => { 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 () => { test('Call should return bad request with invalid sortby query parameters', async () => {
await authOwnerAgent await agents.owner
.get('/insights/by-workflow') .get('/insights/by-workflow')
.query({ .query({
skip: '1', skip: '1',

View File

@@ -1471,7 +1471,8 @@ export type CloudUpdateLinkSourceType =
| 'worker-view' | 'worker-view'
| 'external-secrets' | 'external-secrets'
| 'rbac' | 'rbac'
| 'debug'; | 'debug'
| 'insights';
export type UTMCampaign = export type UTMCampaign =
| 'upgrade-custom-data-filter' | 'upgrade-custom-data-filter'
@@ -1494,7 +1495,8 @@ export type UTMCampaign =
| 'upgrade-worker-view' | 'upgrade-worker-view'
| 'upgrade-external-secrets' | 'upgrade-external-secrets'
| 'upgrade-rbac' | 'upgrade-rbac'
| 'upgrade-debug'; | 'upgrade-debug'
| 'upgrade-insights';
export type N8nBanners = { export type N8nBanners = {
[key in BannerName]: { [key in BannerName]: {

View File

@@ -80,8 +80,10 @@ watch(
void insightsStore.summary.execute(); void insightsStore.summary.execute();
} }
if (insightsStore.isDashboardEnabled) {
void insightsStore.charts.execute(); void insightsStore.charts.execute();
fetchPaginatedTableData({ sortBy: sortTableBy.value }); fetchPaginatedTableData({ sortBy: sortTableBy.value });
}
}, },
{ {
immediate: true, immediate: true,
@@ -102,7 +104,12 @@ watch(
:loading="insightsStore.summary.isLoading" :loading="insightsStore.summary.isLoading"
:class="$style.insightsBanner" :class="$style.insightsBanner"
/> />
<div v-if="insightsStore.isInsightsEnabled" :class="$style.insightsContent"> <div :class="$style.insightsContent">
<InsightsPaywall
v-if="!insightsStore.isDashboardEnabled"
data-test-id="insights-dashboard-unlicensed"
/>
<div v-else>
<div :class="$style.insightsChartWrapper"> <div :class="$style.insightsChartWrapper">
<div v-if="insightsStore.charts.isLoading" :class="$style.chartLoader"> <div v-if="insightsStore.charts.isLoading" :class="$style.chartLoader">
<svg <svg
@@ -136,7 +143,7 @@ watch(
/> />
</div> </div>
</div> </div>
<InsightsPaywall v-else data-test-id="insights-dashboard-unlicensed" /> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,14 +1,27 @@
<script lang="ts" setup></script> <script lang="ts" setup>
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const pageRedirectionHelper = usePageRedirectionHelper();
const i18n = useI18n();
const goToUpgrade = async () => {
await pageRedirectionHelper.goToUpgrade('insights', 'upgrade-insights');
};
</script>
<template> <template>
<div :class="$style.callout"> <div :class="$style.callout">
<N8nIcon icon="lock" size="size"></N8nIcon> <N8nIcon icon="lock" size="xlarge"></N8nIcon>
<N8nText bold tag="h3" size="large">Upgrade to Pro or Enterprise to see full data</N8nText> <N8nText bold tag="h3" size="large">
<N8nText {{ i18n.baseText('insights.dashboard.paywall.title') }}
>Gain access to detailed execution data with one year data retention.
<N8nLink to="/">Learn more</N8nLink>
</N8nText> </N8nText>
<N8nButton type="primary" size="large">Upgrade</N8nButton> <N8nText>
{{ i18n.baseText('insights.dashboard.paywall.description') }}
</N8nText>
<N8nButton type="primary" native-type="button" size="large" @click="goToUpgrade">
{{ i18n.baseText('insights.dashboard.paywall.cta') }}
</N8nButton>
</div> </div>
</template> </template>
@@ -17,6 +30,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 6rem 0;
align-items: center; align-items: center;
max-width: 360px; max-width: 360px;
margin: 0 auto; margin: 0 auto;

View File

@@ -19,6 +19,7 @@ export const useInsightsStore = defineStore('insights', () => {
); );
const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled); const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled);
const isDashboardEnabled = computed(() => settingsStore.settings.insights.dashboard);
const isSummaryEnabled = computed( const isSummaryEnabled = computed(
() => globalInsightsPermissions.value.list && isInsightsEnabled.value, () => globalInsightsPermissions.value.list && isInsightsEnabled.value,
@@ -56,6 +57,7 @@ export const useInsightsStore = defineStore('insights', () => {
globalInsightsPermissions, globalInsightsPermissions,
isInsightsEnabled, isInsightsEnabled,
isSummaryEnabled, isSummaryEnabled,
isDashboardEnabled,
summary, summary,
charts, charts,
table, table,

View File

@@ -3092,6 +3092,9 @@
"insights.dashboard.table.projectName": "Project name", "insights.dashboard.table.projectName": "Project name",
"insights.dashboard.table.estimate": "Estimate", "insights.dashboard.table.estimate": "Estimate",
"insights.dashboard.title": "Insights", "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.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.banner.failureRate.deviation.tooltip": "Percentage point change from previous period",
"insights.chart.failed": "Failed", "insights.chart.failed": "Failed",