diff --git a/packages/frontend/editor-ui/src/components/MainSidebar.vue b/packages/frontend/editor-ui/src/components/MainSidebar.vue index 454bf149a3..dff53c9ee4 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/components/MainSidebar.vue @@ -252,7 +252,7 @@ onBeforeUnmount(() => { const trackTemplatesClick = () => { telemetry.track('User clicked on templates', { - role: usersStore.currentUserCloudInfo?.role, + role: cloudPlanStore.currentUserCloudInfo?.role, active_workflow_count: workflowsStore.activeWorkflows.length, }); }; diff --git a/packages/frontend/editor-ui/src/components/banners/EmailConfirmationBanner.vue b/packages/frontend/editor-ui/src/components/banners/EmailConfirmationBanner.vue index 9e07e33fc2..70f837949a 100644 --- a/packages/frontend/editor-ui/src/components/banners/EmailConfirmationBanner.vue +++ b/packages/frontend/editor-ui/src/components/banners/EmailConfirmationBanner.vue @@ -4,12 +4,13 @@ import { useToast } from '@/composables/useToast'; import { i18n as locale } from '@n8n/i18n'; import { useUsersStore } from '@/stores/users.store'; import { computed } from 'vue'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; const toast = useToast(); +const cloudPlanStore = useCloudPlanStore(); const userEmail = computed(() => { - const { currentUserCloudInfo } = useUsersStore(); - return currentUserCloudInfo?.email ?? ''; + return cloudPlanStore.currentUserCloudInfo?.email ?? ''; }); async function onConfirmEmailClick() { diff --git a/packages/frontend/editor-ui/src/init.test.ts b/packages/frontend/editor-ui/src/init.test.ts index 6a06acba4c..fa33278b93 100644 --- a/packages/frontend/editor-ui/src/init.test.ts +++ b/packages/frontend/editor-ui/src/init.test.ts @@ -10,12 +10,14 @@ import { useSettingsStore } from '@/stores/settings.store'; import { useVersionsStore } from '@/stores/versions.store'; import { AxiosError } from 'axios'; import merge from 'lodash/merge'; -import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; +import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; import { STORES } from '@n8n/stores'; import { useSSOStore } from '@/stores/sso.store'; import { UserManagementAuthenticationMethod } from '@/Interface'; +import type { IUser } from '@/Interface'; import { EnterpriseEditionFeature } from '@/constants'; import { useUIStore } from '@/stores/ui.store'; +import type { Cloud } from '@n8n/rest-api-client'; const showMessage = vi.fn(); const showToast = vi.fn(); @@ -38,7 +40,7 @@ vi.mock('@n8n/stores/useRootStore', () => ({ describe('Init', () => { let settingsStore: ReturnType; - let cloudPlanStore: ReturnType; + let cloudPlanStore: ReturnType>; let sourceControlStore: ReturnType; let usersStore: ReturnType; let nodeTypesStore: ReturnType; @@ -56,7 +58,7 @@ describe('Init', () => { ); settingsStore = useSettingsStore(); - cloudPlanStore = useCloudPlanStore(); + cloudPlanStore = mockedStore(useCloudPlanStore); sourceControlStore = useSourceControlStore(); nodeTypesStore = useNodeTypesStore(); usersStore = useUsersStore(); @@ -215,5 +217,67 @@ describe('Init', () => { expect.anything(), ); }); + + describe('cloudPlanStore', () => { + it('should initialize cloudPlanStore correctly', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(cloudStoreSpy).toHaveBeenCalled(); + }); + + it('should push TRIAL_OVER banner if trial is expired', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + cloudPlanStore.userIsTrialing = true; + cloudPlanStore.trialExpired = true; + + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(cloudStoreSpy).toHaveBeenCalled(); + expect(uiStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL_OVER'); + }); + + it('should push TRIAL banner if trial is active', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + cloudPlanStore.userIsTrialing = true; + cloudPlanStore.trialExpired = false; + + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(cloudStoreSpy).toHaveBeenCalled(); + expect(uiStore.pushBannerToStack).toHaveBeenCalledWith('TRIAL'); + }); + + it('should push EMAIL_CONFIRMATION banner if user cloud info is not confirmed', async () => { + settingsStore.settings.deployment.type = 'cloud'; + usersStore.usersById = { '123': { id: '123', email: '' } as IUser }; + usersStore.currentUserId = '123'; + + cloudPlanStore.userIsTrialing = false; + cloudPlanStore.currentUserCloudInfo = { confirmed: false } as Cloud.UserAccount; + + const cloudStoreSpy = vi.spyOn(cloudPlanStore, 'initialize').mockResolvedValueOnce(); + + await initializeAuthenticatedFeatures(false); + + expect(cloudStoreSpy).toHaveBeenCalled(); + expect(uiStore.pushBannerToStack).toHaveBeenCalledWith('EMAIL_CONFIRMATION'); + }); + }); }); }); diff --git a/packages/frontend/editor-ui/src/init.ts b/packages/frontend/editor-ui/src/init.ts index ece0740165..a8b2738f26 100644 --- a/packages/frontend/editor-ui/src/init.ts +++ b/packages/frontend/editor-ui/src/init.ts @@ -128,6 +128,7 @@ export async function initializeAuthenticatedFeatures( const projectsStore = useProjectsStore(); const rolesStore = useRolesStore(); const insightsStore = useInsightsStore(); + const uiStore = useUIStore(); if (sourceControlStore.isEnterpriseSourceControlEnabled) { try { @@ -150,6 +151,16 @@ export async function initializeAuthenticatedFeatures( if (settingsStore.isCloudDeployment) { try { await cloudPlanStore.initialize(); + + if (cloudPlanStore.userIsTrialing) { + if (cloudPlanStore.trialExpired) { + uiStore.pushBannerToStack('TRIAL_OVER'); + } else { + uiStore.pushBannerToStack('TRIAL'); + } + } else if (!cloudPlanStore.currentUserCloudInfo?.confirmed) { + uiStore.pushBannerToStack('EMAIL_CONFIRMATION'); + } } catch (e) { console.error('Failed to initialize cloud plan store:', e); } diff --git a/packages/frontend/editor-ui/src/stores/cloudPlan.store.ts b/packages/frontend/editor-ui/src/stores/cloudPlan.store.ts index 62131278a7..7ad469ef81 100644 --- a/packages/frontend/editor-ui/src/stores/cloudPlan.store.ts +++ b/packages/frontend/editor-ui/src/stores/cloudPlan.store.ts @@ -1,10 +1,9 @@ -import { computed, reactive } from 'vue'; +import { computed, reactive, ref } from 'vue'; import { defineStore } from 'pinia'; import type { CloudPlanState } from '@/Interface'; import { useRootStore } from '@n8n/stores/useRootStore'; import { useSettingsStore } from '@/stores/settings.store'; -import { useUIStore } from '@/stores/ui.store'; -import { useUsersStore } from '@/stores/users.store'; +import type { Cloud } from '@n8n/rest-api-client/api/cloudPlans'; import { getAdminPanelLoginCode, getCurrentPlan, @@ -14,6 +13,7 @@ import { DateTime } from 'luxon'; import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/constants'; import { STORES } from '@n8n/stores'; import { hasPermission } from '@/utils/rbac/permissions'; +import * as cloudApi from '@n8n/rest-api-client/api/cloudPlans'; const DEFAULT_STATE: CloudPlanState = { initialized: false, @@ -25,11 +25,12 @@ const DEFAULT_STATE: CloudPlanState = { export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); - const usersStore = useUsersStore(); const state = reactive(DEFAULT_STATE); + const currentUserCloudInfo = ref(null); const reset = () => { + currentUserCloudInfo.value = null; state.data = null; state.usage = null; }; @@ -58,11 +59,10 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { const getUserCloudAccount = async () => { if (!hasCloudPlan.value) throw new Error('User does not have a cloud plan'); + let cloudUser: Cloud.UserAccount | null = null; try { - await usersStore.fetchUserCloudAccount(); - if (!usersStore.currentUserCloudInfo?.confirmed && !userIsTrialing.value) { - useUIStore().pushBannerToStack('EMAIL_CONFIRMATION'); - } + cloudUser = await cloudApi.getCloudUserInfo(rootStore.restApiContext); + currentUserCloudInfo.value = cloudUser; } catch (error) { throw new Error(error.message); } @@ -80,14 +80,6 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { plan = await getCurrentPlan(rootStore.restApiContext); state.data = plan; state.loadingPlan = false; - - if (userIsTrialing.value) { - if (trialExpired.value) { - useUIStore().pushBannerToStack('TRIAL_OVER'); - } else { - useUIStore().pushBannerToStack('TRIAL'); - } - } } catch (error) { state.loadingPlan = false; throw new Error(error); @@ -194,6 +186,7 @@ export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { trialExpired, allExecutionsUsed, hasCloudPlan, + currentUserCloudInfo, generateCloudDashboardAutoLoginLink, initialize, getOwnerCurrentPlan, diff --git a/packages/frontend/editor-ui/src/stores/posthog.test.ts b/packages/frontend/editor-ui/src/stores/posthog.test.ts index 7ae65f4eae..fe1857d726 100644 --- a/packages/frontend/editor-ui/src/stores/posthog.test.ts +++ b/packages/frontend/editor-ui/src/stores/posthog.test.ts @@ -8,6 +8,7 @@ import { LOCAL_STORAGE_EXPERIMENT_OVERRIDES } from '@/constants'; import { nextTick } from 'vue'; import { defaultSettings } from '../__tests__/defaults'; import { useTelemetry } from '@/composables/useTelemetry'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; export const DEFAULT_POSTHOG_SETTINGS: FrontendSettings['posthog'] = { enabled: true, @@ -49,7 +50,9 @@ function resetStores() { usersStore.initialized = false; usersStore.currentUserId = null; usersStore.usersById = {}; - usersStore.currentUserCloudInfo = null; + + const cloudPlanStore = useCloudPlanStore(); + cloudPlanStore.currentUserCloudInfo = null; } function setup() { diff --git a/packages/frontend/editor-ui/src/stores/templates.store.ts b/packages/frontend/editor-ui/src/stores/templates.store.ts index 2ea2adba83..35800e1b75 100644 --- a/packages/frontend/editor-ui/src/stores/templates.store.ts +++ b/packages/frontend/editor-ui/src/stores/templates.store.ts @@ -18,6 +18,7 @@ import { useRootStore } from '@n8n/stores/useRootStore'; import { useUsersStore } from './users.store'; import { useWorkflowsStore } from './workflows.store'; import { computed, ref } from 'vue'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; export interface ITemplateState { categories: ITemplatesCategory[]; @@ -81,6 +82,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => { const settingsStore = useSettingsStore(); const rootStore = useRootStore(); const userStore = useUsersStore(); + const cloudPlanStore = useCloudPlanStore(); const workflowsStore = useWorkflowsStore(); const allCategories = computed(() => { @@ -171,7 +173,7 @@ export const useTemplatesStore = defineStore(STORES.TEMPLATES, () => { utm_awc: String(workflowsStore.activeWorkflows.length), }; const userRole: string | null | undefined = - userStore.currentUserCloudInfo?.role ?? + cloudPlanStore.currentUserCloudInfo?.role ?? (userStore.currentUser?.personalizationAnswers && 'role' in userStore.currentUser.personalizationAnswers ? userStore.currentUser.personalizationAnswers.role diff --git a/packages/frontend/editor-ui/src/stores/ui.test.ts b/packages/frontend/editor-ui/src/stores/ui.test.ts index d300422f80..17c384dfcc 100644 --- a/packages/frontend/editor-ui/src/stores/ui.test.ts +++ b/packages/frontend/editor-ui/src/stores/ui.test.ts @@ -1,54 +1,16 @@ import { createPinia, setActivePinia } from 'pinia'; import { useUIStore } from '@/stores/ui.store'; -import { useSettingsStore } from '@/stores/settings.store'; -import { useUsersStore } from '@/stores/users.store'; -import merge from 'lodash/merge'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; -import * as cloudPlanApi from '@n8n/rest-api-client/api/cloudPlans'; -import { defaultSettings } from '../__tests__/defaults'; -import { - getTrialExpiredUserResponse, - getTrialingUserResponse, - getUserCloudInfo, - getNotTrialingUserResponse, -} from './__tests__/utils/cloudStoreUtils'; -import { ROLE, type Role } from '@n8n/api-types'; let uiStore: ReturnType; -let settingsStore: ReturnType; let cloudPlanStore: ReturnType; -function setUser(role: Role) { - useUsersStore().addUsers([ - { - id: '1', - isPending: false, - role, - }, - ]); - - useUsersStore().currentUserId = '1'; -} - -function setupOwnerAndCloudDeployment() { - setUser(ROLE.Owner); - settingsStore.setSettings( - merge({}, defaultSettings, { - n8nMetadata: { - userId: '1', - }, - deployment: { type: 'cloud' }, - }), - ); -} - describe('UI store', () => { let mockedCloudStore; beforeEach(() => { setActivePinia(createPinia()); uiStore = useUIStore(); - settingsStore = useSettingsStore(); cloudPlanStore = useCloudPlanStore(); @@ -90,62 +52,4 @@ describe('UI store', () => { expect(uiStore.bannerStack).not.toContain('V1'); }); - - it('should add trial banner to the the stack', async () => { - const fetchCloudSpy = vi - .spyOn(cloudPlanApi, 'getCurrentPlan') - .mockResolvedValue(getTrialingUserResponse()); - const fetchUserCloudAccountSpy = vi - .spyOn(cloudPlanApi, 'getCloudUserInfo') - .mockResolvedValue(getUserCloudInfo(true)); - const getCurrentUsageSpy = vi - .spyOn(cloudPlanApi, 'getCurrentUsage') - .mockResolvedValue({ executions: 1000, activeWorkflows: 100 }); - setupOwnerAndCloudDeployment(); - await cloudPlanStore.checkForCloudPlanData(); - await cloudPlanStore.fetchUserCloudAccount(); - expect(fetchCloudSpy).toHaveBeenCalled(); - expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); - expect(getCurrentUsageSpy).toHaveBeenCalled(); - expect(uiStore.bannerStack).toContain('TRIAL'); - // There should be no email confirmation banner for trialing users - expect(uiStore.bannerStack).not.toContain('EMAIL_CONFIRMATION'); - }); - - it('should add trial over banner to the the stack', async () => { - const fetchCloudSpy = vi - .spyOn(cloudPlanApi, 'getCurrentPlan') - .mockResolvedValue(getTrialExpiredUserResponse()); - const fetchUserCloudAccountSpy = vi - .spyOn(cloudPlanApi, 'getCloudUserInfo') - .mockResolvedValue(getUserCloudInfo(true)); - setupOwnerAndCloudDeployment(); - const getCurrentUsageSpy = vi - .spyOn(cloudPlanApi, 'getCurrentUsage') - .mockResolvedValue({ executions: 1000, activeWorkflows: 100 }); - setupOwnerAndCloudDeployment(); - await cloudPlanStore.checkForCloudPlanData(); - await cloudPlanStore.fetchUserCloudAccount(); - expect(fetchCloudSpy).toHaveBeenCalled(); - expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); - expect(getCurrentUsageSpy).toHaveBeenCalled(); - expect(uiStore.bannerStack).toContain('TRIAL_OVER'); - // There should be no email confirmation banner for trialing users - expect(uiStore.bannerStack).not.toContain('EMAIL_CONFIRMATION'); - }); - - it('should add email confirmation banner to the the stack', async () => { - const fetchCloudSpy = vi - .spyOn(cloudPlanApi, 'getCurrentPlan') - .mockResolvedValue(getNotTrialingUserResponse()); - const fetchUserCloudAccountSpy = vi - .spyOn(cloudPlanApi, 'getCloudUserInfo') - .mockResolvedValue(getUserCloudInfo(false)); - setupOwnerAndCloudDeployment(); - await cloudPlanStore.checkForCloudPlanData(); - await cloudPlanStore.fetchUserCloudAccount(); - expect(fetchCloudSpy).toHaveBeenCalled(); - expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); - expect(uiStore.bannerStack).toContain('EMAIL_CONFIRMATION'); - }); }); diff --git a/packages/frontend/editor-ui/src/stores/users.store.ts b/packages/frontend/editor-ui/src/stores/users.store.ts index bba3a99013..8a14ebc638 100644 --- a/packages/frontend/editor-ui/src/stores/users.store.ts +++ b/packages/frontend/editor-ui/src/stores/users.store.ts @@ -18,7 +18,6 @@ import type { CurrentUserResponse, InvitableRoleName, } from '@/Interface'; -import type { Cloud } from '@n8n/rest-api-client/api/cloudPlans'; import { getPersonalizedNodeTypes } from '@/utils/userUtils'; import { defineStore } from 'pinia'; import { useRootStore } from '@n8n/stores/useRootStore'; @@ -43,7 +42,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => { const initialized = ref(false); const currentUserId = ref(null); const usersById = ref>({}); - const currentUserCloudInfo = ref(null); const userQuota = ref(-1); const loginHooks = ref([]); @@ -179,7 +177,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => { const unsetCurrentUser = () => { currentUserId.value = null; - currentUserCloudInfo.value = null; }; const deleteUserById = (userId: string) => { @@ -391,16 +388,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => { } }; - const fetchUserCloudAccount = async () => { - let cloudUser: Cloud.UserAccount | null = null; - try { - cloudUser = await cloudApi.getCloudUserInfo(rootStore.restApiContext); - currentUserCloudInfo.value = cloudUser; - } catch (error) { - throw new Error(error); - } - }; - const sendConfirmationEmail = async () => { await cloudApi.sendConfirmationEmail(rootStore.restApiContext); }; @@ -438,7 +425,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => { initialized, currentUserId, usersById, - currentUserCloudInfo, allUsers, currentUser, userActivated, @@ -481,7 +467,6 @@ export const useUsersStore = defineStore(STORES.USERS, () => { enableMfa, disableMfa, canEnableMFA, - fetchUserCloudAccount, sendConfirmationEmail, updateGlobalRole, setEasyAIWorkflowOnboardingDone, diff --git a/packages/frontend/editor-ui/src/views/SigninView.vue b/packages/frontend/editor-ui/src/views/SigninView.vue index 514b584d25..baf6587ab7 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.vue +++ b/packages/frontend/editor-ui/src/views/SigninView.vue @@ -11,7 +11,6 @@ import { useTelemetry } from '@/composables/useTelemetry'; import { useUsersStore } from '@/stores/users.store'; import { useSettingsStore } from '@/stores/settings.store'; -import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useSSOStore } from '@/stores/sso.store'; import type { IFormBoxConfig } from '@/Interface'; @@ -27,7 +26,6 @@ export type MfaCodeOrMfaRecoveryCode = Pick { mfaRecoveryCode: form.mfaRecoveryCode, }); loading.value = false; - if (settingsStore.isCloudDeployment) { - try { - await cloudPlanStore.checkForCloudPlanData(); - } catch (error) { - console.warn('Failed to check for cloud plan data', error); - } - } await settingsStore.getSettings(); if (settingsStore.activeModules.length > 0) {