From b0e98b59a6500b11f306403c563191749478c3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Thu, 21 Sep 2023 09:47:21 +0200 Subject: [PATCH] feat(editor): Rework banners framework and add email confirmation banner (#7205) This PR introduces banner framework overhaul: First version of the banner framework was built to allow multiple banners to be shown at the same time. Since that proven to be the case we don't need and it turned out to be pretty messy keeping only one banner visible in such setup, this PR reworks it so it renders only one banner at a time, based on [this priority list](https://www.notion.so/n8n/Banner-stack-60948c4167c743718fde80d6745258d5?pvs=4#6afd052ec8d146a1b0fab8884a19add7) that is assembled together with our product & design team. ### How to test banner stack: 1. Available banners and their priorities are registered [here](https://github.com/n8n-io/n8n/blob/f9f122d46d26565a4cc5dcf63060e7ed9f359e53/packages/editor-ui/src/components/banners/BannerStack.vue#L14) 2. Banners are pushed to stack using `pushBannerToStack` action, for example: ``` useUIStore().pushBannerToStack('TRIAL'); ``` 4. Try pushing different banners to stack and check if only the one with highest priorities is showing up ### How to test the _Email confirmation_ banner: 1. Comment out [this line](https://github.com/n8n-io/n8n/blob/b80d2e3bec59a9abe141a4c808ea2b7f5d9fecce/packages/editor-ui/src/stores/cloudPlan.store.ts#L59), so cloud data is always fetched 2. Create an [override](https://chrome.google.com/webstore/detail/resource-override/pkoacgokdfckfpndoffpifphamojphii) (URL -> File) that will serve user data that triggers this banner: - **URL**: `*/rest/cloud/proxy/admin/user/me` - **File**: ``` { "confirmed": false, "id": 1, "email": "test@test.com", "username": "test" } ``` 3. Run n8n --- packages/editor-ui/src/App.vue | 4 - packages/editor-ui/src/Interface.ts | 18 ++- packages/editor-ui/src/api/cloudPlans.ts | 10 +- .../components/MainHeader/WorkflowDetails.vue | 2 - .../components/__tests__/BannersStack.test.ts | 113 ++++++++++------- .../src/components/banners/BannerStack.vue | 49 +++++--- .../src/components/banners/BaseBanner.vue | 12 +- .../banners/EmailConfirmationBanner.vue | 54 ++++++++ packages/editor-ui/src/constants.ts | 1 + .../src/plugins/i18n/locales/en.json | 7 ++ .../editor-ui/src/stores/__tests__/ui.test.ts | 117 +++++++++++++++++- .../stores/__tests__/utils/cloudStoreUtils.ts | 44 +++++++ .../editor-ui/src/stores/cloudPlan.store.ts | 20 ++- .../editor-ui/src/stores/settings.store.ts | 20 ++- packages/editor-ui/src/stores/ui.store.ts | 44 ++----- packages/editor-ui/src/stores/users.store.ts | 18 ++- packages/editor-ui/src/views/SigninView.vue | 2 +- packages/workflow/src/Interfaces.ts | 7 +- 18 files changed, 424 insertions(+), 118 deletions(-) create mode 100644 packages/editor-ui/src/components/banners/EmailConfirmationBanner.vue create mode 100644 packages/editor-ui/src/stores/__tests__/utils/cloudStoreUtils.ts diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 503f0a1a0a9e7..a03cf59c99794 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -143,9 +143,6 @@ export default defineComponent({ console.log(HIRING_BANNER); } }, - async initBanners() { - return this.uiStore.initBanners(); - }, async checkForCloudPlanData() { return this.cloudPlanStore.checkForCloudPlanData(); }, @@ -239,7 +236,6 @@ export default defineComponent({ await this.redirectIfNecessary(); void this.checkForNewVersions(); await 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 7988eaa838947..eeebd22d1d504 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -45,6 +45,7 @@ import type { } from 'n8n-workflow'; import type { BulkCommand, Undoable } from '@/models/history'; import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; +import type { Component } from 'vue'; export * from 'n8n-design-system/types'; @@ -1084,7 +1085,7 @@ export interface UIState { addFirstStepOnLoad: boolean; executionSidebarAutoRefresh: boolean; bannersHeight: number; - banners: { [key in BannerName]: { dismissed: boolean; type?: 'temporary' | 'permanent' } }; + bannerStack: BannerName[]; } export type IFakeDoor = { @@ -1192,6 +1193,7 @@ export interface IVersionsState { export interface IUsersState { currentUserId: null | string; users: { [userId: string]: IUser }; + currentUserCloudInfo: Cloud.UserAccount | null; } export interface IWorkflowsState { @@ -1529,6 +1531,13 @@ export declare namespace Cloud { length: number; gracePeriod: number; } + + export type UserAccount = { + confirmed: boolean; + username: string; + email: string; + hasEarlyAccess?: boolean; + }; } export interface CloudPlanState { @@ -1594,3 +1603,10 @@ export type UTMCampaign = | 'open' | 'upgrade-users' | 'upgrade-variables'; + +export type N8nBanners = { + [key in BannerName]: { + priority: number; + component: Component; + }; +}; diff --git a/packages/editor-ui/src/api/cloudPlans.ts b/packages/editor-ui/src/api/cloudPlans.ts index e0d15299dc54f..c0a8dfe79766a 100644 --- a/packages/editor-ui/src/api/cloudPlans.ts +++ b/packages/editor-ui/src/api/cloudPlans.ts @@ -1,5 +1,5 @@ import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface'; -import { get } from '@/utils'; +import { get, post } from '@/utils'; export async function getCurrentPlan(context: IRestApiContext): Promise { return get(context.baseUrl, '/admin/cloud-plan'); @@ -8,3 +8,11 @@ export async function getCurrentPlan(context: IRestApiContext): Promise { return get(context.baseUrl, '/cloud/limits'); } + +export async function getCloudUserInfo(context: IRestApiContext): Promise { + return get(context.baseUrl, '/cloud/proxy/admin/user/me'); +} + +export async function confirmEmail(context: IRestApiContext): Promise { + return post(context.baseUrl, '/cloud/proxy/admin/user/resend-confirmation-email'); +} diff --git a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue index c22934e85acf2..8c046dbd1c02e 100644 --- a/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -163,7 +163,6 @@ import { import type { IPermissions } from '@/permissions'; import { getWorkflowPermissions } from '@/permissions'; import { createEventBus } from 'n8n-design-system/utils'; -import { useCloudPlanStore } from '@/stores'; import { nodeViewEventBus } from '@/event-bus'; import { genericHelpers } from '@/mixins/genericHelpers'; @@ -223,7 +222,6 @@ export default defineComponent({ useUsageStore, useWorkflowsStore, useUsersStore, - useCloudPlanStore, useSourceControlStore, ), currentUser(): IUser | null { diff --git a/packages/editor-ui/src/components/__tests__/BannersStack.test.ts b/packages/editor-ui/src/components/__tests__/BannersStack.test.ts index 7458b9804f0f8..25da40a044737 100644 --- a/packages/editor-ui/src/components/__tests__/BannersStack.test.ts +++ b/packages/editor-ui/src/components/__tests__/BannersStack.test.ts @@ -1,4 +1,3 @@ -import { within } from '@testing-library/vue'; import { merge } from 'lodash-es'; import userEvent from '@testing-library/user-event'; @@ -11,6 +10,7 @@ import { useUIStore } from '@/stores/ui.store'; import { useUsersStore } from '@/stores/users.store'; import type { RenderOptions } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render'; +import { waitFor } from '@testing-library/vue'; let uiStore: ReturnType; let usersStore: ReturnType; @@ -20,11 +20,7 @@ const initialState = { settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings), }, [STORES.UI]: { - banners: { - V1: { dismissed: false }, - TRIAL: { dismissed: false }, - TRIAL_OVER: { dismissed: false }, - }, + bannerStack: ['TRIAL_OVER', 'V1', 'NON_PRODUCTION_LICENSE', 'EMAIL_CONFIRMATION'], }, [STORES.USERS]: { currentUserId: 'aaa-bbb', @@ -66,37 +62,14 @@ describe('BannerStack', () => { vi.clearAllMocks(); }); - it('should render default configuration', async () => { - const { getByTestId } = renderComponent(); - - const bannerStack = getByTestId('banner-stack'); - expect(bannerStack).toBeInTheDocument(); - - expect(within(bannerStack).getByTestId('banners-TRIAL')).toBeInTheDocument(); - expect(within(bannerStack).getByTestId('banners-TRIAL_OVER')).toBeInTheDocument(); - expect(within(bannerStack).getByTestId('banners-V1')).toBeInTheDocument(); - }); - - it('should not render dismissed banners', async () => { - const { getByTestId } = renderComponent({ - pinia: createTestingPinia({ - initialState: merge(initialState, { - [STORES.UI]: { - banners: { - V1: { dismissed: true }, - TRIAL: { dismissed: true }, - }, - }, - }), - }), - }); + it('should render banner with the highest priority', async () => { + const { getByTestId, queryByTestId } = renderComponent(); const bannerStack = getByTestId('banner-stack'); expect(bannerStack).toBeInTheDocument(); - - expect(within(bannerStack).queryByTestId('banners-V1')).not.toBeInTheDocument(); - expect(within(bannerStack).queryByTestId('banners-TRIAL')).not.toBeInTheDocument(); - expect(within(bannerStack).getByTestId('banners-TRIAL_OVER')).toBeInTheDocument(); + // Only V1 banner should be visible + expect(getByTestId('banners-V1')).toBeInTheDocument(); + expect(queryByTestId('banners-TRIAL_OVER')).not.toBeInTheDocument(); }); it('should dismiss banner on click', async () => { @@ -104,24 +77,15 @@ describe('BannerStack', () => { const dismissBannerSpy = vi .spyOn(useUIStore(), 'dismissBanner') .mockImplementation(async (banner, mode) => {}); - const closeTrialBannerButton = getByTestId('banner-TRIAL_OVER-close'); + expect(getByTestId('banners-V1')).toBeInTheDocument(); + const closeTrialBannerButton = getByTestId('banner-V1-close'); expect(closeTrialBannerButton).toBeInTheDocument(); await userEvent.click(closeTrialBannerButton); - expect(dismissBannerSpy).toHaveBeenCalledWith('TRIAL_OVER'); + expect(dismissBannerSpy).toHaveBeenCalledWith('V1'); }); it('should permanently dismiss banner on click', async () => { - const { getByTestId } = renderComponent({ - pinia: createTestingPinia({ - initialState: merge(initialState, { - [STORES.UI]: { - banners: { - V1: { dismissed: false }, - }, - }, - }), - }), - }); + const { getByTestId } = renderComponent(); const dismissBannerSpy = vi .spyOn(useUIStore(), 'dismissBanner') .mockImplementation(async (banner, mode) => {}); @@ -144,4 +108,59 @@ describe('BannerStack', () => { }); expect(queryByTestId('banner-confirm-v1')).not.toBeInTheDocument(); }); + + it('should send email confirmation request from the banner', async () => { + const { getByTestId, getByText } = renderComponent({ + pinia: createTestingPinia({ + initialState: { + ...initialState, + [STORES.UI]: { + bannerStack: ['EMAIL_CONFIRMATION'], + }, + }, + }), + }); + const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail'); + getByTestId('confirm-email-button').click(); + await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled()); + await waitFor(() => { + expect(getByText('Confirmation email sent')).toBeInTheDocument(); + }); + }); + + it('should show error message if email confirmation fails', async () => { + const ERROR_MESSAGE = 'Something went wrong'; + const { getByTestId, getByText } = renderComponent({ + pinia: createTestingPinia({ + initialState: { + ...initialState, + [STORES.UI]: { + bannerStack: ['EMAIL_CONFIRMATION'], + }, + }, + }), + }); + const confirmEmailSpy = vi.spyOn(useUsersStore(), 'confirmEmail').mockImplementation(() => { + throw new Error(ERROR_MESSAGE); + }); + getByTestId('confirm-email-button').click(); + await waitFor(() => expect(confirmEmailSpy).toHaveBeenCalled()); + await waitFor(() => { + expect(getByText(ERROR_MESSAGE)).toBeInTheDocument(); + }); + }); + + it('should render empty banner stack when there are no banners to display', async () => { + const { queryByTestId } = renderComponent({ + pinia: createTestingPinia({ + initialState: { + ...initialState, + [STORES.UI]: { + bannerStack: [], + }, + }, + }), + }); + expect(queryByTestId('banner-stack')).toBeEmptyDOMElement(); + }); }); diff --git a/packages/editor-ui/src/components/banners/BannerStack.vue b/packages/editor-ui/src/components/banners/BannerStack.vue index 248ac67f70a16..a52b7e696f559 100644 --- a/packages/editor-ui/src/components/banners/BannerStack.vue +++ b/packages/editor-ui/src/components/banners/BannerStack.vue @@ -1,38 +1,59 @@ - + + diff --git a/packages/editor-ui/src/components/banners/BaseBanner.vue b/packages/editor-ui/src/components/banners/BaseBanner.vue index 3ede4c71b789f..5b968c5860ede 100644 --- a/packages/editor-ui/src/components/banners/BaseBanner.vue +++ b/packages/editor-ui/src/components/banners/BaseBanner.vue @@ -1,5 +1,6 @@ + + diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 443bb434765da..a6a0694b65365 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -515,6 +515,7 @@ export const enum STORES { NODE_CREATOR = 'nodeCreator', WEBHOOKS = 'webhooks', HISTORY = 'history', + CLOUD_PLAN = 'cloudPlan', } export const enum SignInType { diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index ff43e76679106..328f829414876 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -116,6 +116,13 @@ "auth.signup.setupYourAccount": "Set up your account", "auth.signup.setupYourAccountError": "Problem setting up your account", "auth.signup.tokenValidationError": "Issue validating invite token", + "banners.confirmEmail.message.1": "You need access to your admin email inbox to make sure you can reach your admin dashboard. Please make sure that your admin email", + "banners.confirmEmail.message.2": "is accessible and confirmed.", + "banners.confirmEmail.button": "Confirm email", + "banners.confirmEmail.toast.success.heading": "Confirmation email sent", + "banners.confirmEmail.toast.success.message": "Please check your inbox and click the confirmation link.", + "banners.confirmEmail.toast.error.heading": "Problem sending confirmation email", + "banners.confirmEmail.toast.error.message": "Please try again later.", "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.", diff --git a/packages/editor-ui/src/stores/__tests__/ui.test.ts b/packages/editor-ui/src/stores/__tests__/ui.test.ts index 59be0c810c0b7..898f02000be22 100644 --- a/packages/editor-ui/src/stores/__tests__/ui.test.ts +++ b/packages/editor-ui/src/stores/__tests__/ui.test.ts @@ -1,17 +1,57 @@ import { createPinia, setActivePinia } from 'pinia'; import { useUIStore } from '@/stores/ui.store'; -import { useSettingsStore } from '@/stores/settings.store'; +import { useSettingsStore, useUsersStore } from '@/stores/settings.store'; import { merge } from 'lodash-es'; import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; +import { useRootStore } from '@/stores/n8nRoot.store'; +import { useCloudPlanStore } from '@/stores/cloudPlan.store'; +import * as cloudPlanApi from '@/api/cloudPlans'; +import { + getTrialExpiredUserResponse, + getTrialingUserResponse, + getUserCloudInfo, +} from './utils/cloudStoreUtils'; let uiStore: ReturnType; let settingsStore: ReturnType; +let rootStore: ReturnType; +let cloudPlanStore: ReturnType; + +function setOwnerUser() { + useUsersStore().addUsers([ + { + id: '1', + isPending: false, + globalRole: { + id: '1', + name: 'owner', + createdAt: new Date(), + }, + }, + ]); + + useUsersStore().currentUserId = '1'; +} + +function setupOwnerAndCloudDeployment() { + setOwnerUser(); + settingsStore.setSettings( + merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + n8nMetadata: { + userId: '1', + }, + deployment: { type: 'cloud' }, + }), + ); +} describe('UI store', () => { beforeEach(() => { setActivePinia(createPinia()); uiStore = useUIStore(); settingsStore = useSettingsStore(); + rootStore = useRootStore(); + cloudPlanStore = useCloudPlanStore(); }); test.each([ @@ -42,4 +82,79 @@ describe('UI store', () => { expect(uiStore.upgradeLinkUrl('test_source', 'utm-test-campaign')).toBe(expectation); }, ); + + it('should add non-production license banner to stack based on enterprise settings', () => { + settingsStore.setSettings( + merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + enterprise: { + showNonProdBanner: true, + }, + }), + ); + expect(uiStore.bannerStack).toContain('NON_PRODUCTION_LICENSE'); + }); + + it("should add V1 banner to stack if it's not dismissed", () => { + settingsStore.setSettings( + merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + versionCli: '1.0.0', + }), + ); + expect(uiStore.bannerStack).toContain('V1'); + }); + + it("should not add V1 banner to stack if it's dismissed", () => { + settingsStore.setSettings( + merge({}, SETTINGS_STORE_DEFAULT_STATE.settings, { + versionCli: '1.0.0', + banners: { + dismissed: ['V1'], + }, + }), + ); + 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)); + setupOwnerAndCloudDeployment(); + await cloudPlanStore.getOwnerCurrentPlan(); + expect(fetchCloudSpy).toHaveBeenCalled(); + expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); + expect(uiStore.bannerStack).toContain('TRIAL'); + }); + + 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(); + await cloudPlanStore.getOwnerCurrentPlan(); + expect(fetchCloudSpy).toHaveBeenCalled(); + expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); + expect(uiStore.bannerStack).toContain('TRIAL_OVER'); + }); + + it('should add email confirmation banner to the the stack', async () => { + const fetchCloudSpy = vi + .spyOn(cloudPlanApi, 'getCurrentPlan') + .mockResolvedValue(getTrialExpiredUserResponse()); + const fetchUserCloudAccountSpy = vi + .spyOn(cloudPlanApi, 'getCloudUserInfo') + .mockResolvedValue(getUserCloudInfo(false)); + setupOwnerAndCloudDeployment(); + await cloudPlanStore.getOwnerCurrentPlan(); + expect(fetchCloudSpy).toHaveBeenCalled(); + expect(fetchUserCloudAccountSpy).toHaveBeenCalled(); + expect(uiStore.bannerStack).toContain('TRIAL_OVER'); + expect(uiStore.bannerStack).toContain('EMAIL_CONFIRMATION'); + }); }); diff --git a/packages/editor-ui/src/stores/__tests__/utils/cloudStoreUtils.ts b/packages/editor-ui/src/stores/__tests__/utils/cloudStoreUtils.ts new file mode 100644 index 0000000000000..448f4ba421ffd --- /dev/null +++ b/packages/editor-ui/src/stores/__tests__/utils/cloudStoreUtils.ts @@ -0,0 +1,44 @@ +import type { Cloud } from '@/Interface'; + +// Mocks cloud plan API responses with different trial expiration dates +function getUserPlanData(trialExpirationDate: Date): Cloud.PlanData { + return { + planId: 0, + monthlyExecutionsLimit: 1000, + activeWorkflowsLimit: 10, + credentialsLimit: 100, + isActive: true, + displayName: 'Trial', + metadata: { + group: 'trial', + slug: 'trial-1', + trial: { + gracePeriod: 3, + length: 7, + }, + version: 'v1', + }, + expirationDate: trialExpirationDate.toISOString(), + }; +} + +// Mocks cloud user API responses with different confirmed states +export function getUserCloudInfo(confirmed: boolean): Cloud.UserAccount { + return { + confirmed, + email: 'test@test.com', + username: 'test', + }; +} + +export function getTrialingUserResponse(): Cloud.PlanData { + const dateInThePast = new Date(); + dateInThePast.setDate(dateInThePast.getDate() + 3); + return getUserPlanData(dateInThePast); +} + +export function getTrialExpiredUserResponse(): Cloud.PlanData { + const dateInThePast = new Date(); + dateInThePast.setDate(dateInThePast.getDate() - 3); + return getUserPlanData(dateInThePast); +} diff --git a/packages/editor-ui/src/stores/cloudPlan.store.ts b/packages/editor-ui/src/stores/cloudPlan.store.ts index cc6e85bf7e539..f736aa8bba7a2 100644 --- a/packages/editor-ui/src/stores/cloudPlan.store.ts +++ b/packages/editor-ui/src/stores/cloudPlan.store.ts @@ -3,10 +3,11 @@ import { defineStore } from 'pinia'; import type { CloudPlanState } from '@/Interface'; import { useRootStore } from '@/stores/n8nRoot.store'; import { useSettingsStore } from '@/stores/settings.store'; +import { useUIStore } from '@/stores/ui.store'; import { useUsersStore } from '@/stores/users.store'; import { getCurrentPlan, getCurrentUsage } from '@/api/cloudPlans'; import { DateTime } from 'luxon'; -import { CLOUD_TRIAL_CHECK_INTERVAL } from '@/constants'; +import { CLOUD_TRIAL_CHECK_INTERVAL, STORES } from '@/constants'; const DEFAULT_STATE: CloudPlanState = { data: null, @@ -14,7 +15,7 @@ const DEFAULT_STATE: CloudPlanState = { loadingPlan: false, }; -export const useCloudPlanStore = defineStore('cloudPlan', () => { +export const useCloudPlanStore = defineStore(STORES.CLOUD_PLAN, () => { const rootStore = useRootStore(); const settingsStore = useSettingsStore(); const usersStore = useUsersStore(); @@ -62,6 +63,21 @@ export const useCloudPlanStore = defineStore('cloudPlan', () => { plan = await getCurrentPlan(rootStore.getRestApiContext); state.data = plan; state.loadingPlan = false; + + if (userIsTrialing.value) { + if (trialExpired.value) { + useUIStore().pushBannerToStack('TRIAL_OVER'); + } else { + useUIStore().pushBannerToStack('TRIAL'); + } + } + + if (useUsersStore().isInstanceOwner) { + await usersStore.fetchUserCloudAccount(); + if (!usersStore.currentUserCloudInfo?.confirmed) { + useUIStore().pushBannerToStack('EMAIL_CONFIRMATION'); + } + } } catch (error) { state.loadingPlan = false; throw new Error(error); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 5f25ca38fe41f..ae2d0cae0d0ec 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -31,7 +31,6 @@ import { useUIStore } from './ui.store'; import { useUsersStore } from './users.store'; import { useVersionsStore } from './versions.store'; import { makeRestApiRequest } from '@/utils'; -import { useCloudPlanStore } from './cloudPlan.store'; export const useSettingsStore = defineStore(STORES.SETTINGS, { state: (): ISettingsState => ({ @@ -205,7 +204,15 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { this.saml.loginLabel = settings.sso.saml.loginLabel; } if (settings.enterprise?.showNonProdBanner) { - useUIStore().banners.NON_PRODUCTION_LICENSE.dismissed = false; + useUIStore().pushBannerToStack('NON_PRODUCTION_LICENSE'); + } + if (settings.versionCli) { + useRootStore().setVersionCli(settings.versionCli); + } + + const isV1BannerDismissedPermanently = (settings.banners?.dismissed || []).includes('V1'); + if (!isV1BannerDismissedPermanently && useRootStore().versionCli.startsWith('1.')) { + useUIStore().pushBannerToStack('V1'); } }, async getSettings(): Promise { @@ -233,15 +240,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { rootStore.setDefaultLocale(settings.defaultLocale); rootStore.setIsNpmAvailable(settings.isNpmAvailable); - const isV1BannerDismissedPermanently = settings.banners.dismissed.includes('V1'); - if ( - !isV1BannerDismissedPermanently && - useRootStore().versionCli.startsWith('1.') && - !useCloudPlanStore().userIsTrialing - ) { - useUIStore().showBanner('V1'); - } - useVersionsStore().setVersionNotificationSettings(settings.versionNotifications); }, stopShowingSetupPage(): void { diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 8fb69aa968a51..ec9835f53c375 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -49,9 +49,9 @@ import type { NewCredentialsModal, } from '@/Interface'; import { defineStore } from 'pinia'; -import { useRootStore } from './n8nRoot.store'; +import { useRootStore } from '@/stores/n8nRoot.store'; import { getCurlToJson } from '@/api/curlHelper'; -import { useWorkflowsStore } from './workflows.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; import { useSettingsStore } from '@/stores/settings.store'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import type { BaseTextKey } from '@/plugins/i18n'; @@ -184,13 +184,8 @@ export const useUIStore = defineStore(STORES.UI, { nodeViewInitialized: false, addFirstStepOnLoad: false, executionSidebarAutoRefresh: true, - banners: { - V1: { dismissed: true }, - TRIAL: { dismissed: true }, - TRIAL_OVER: { dismissed: true }, - NON_PRODUCTION_LICENSE: { dismissed: true }, - }, bannersHeight: 0, + bannerStack: [], }), getters: { contextBasedTranslationKeys() { @@ -563,36 +558,23 @@ export const useUIStore = defineStore(STORES.UI, { bannerName: name, dismissedBanners: useSettingsStore().permanentlyDismissedBanners, }); - this.banners[name].dismissed = true; - this.banners[name].type = 'permanent'; + this.removeBannerFromStack(name); return; } - this.banners[name].dismissed = true; - this.banners[name].type = 'temporary'; - }, - showBanner(name: BannerName): void { - this.banners[name].dismissed = false; + this.removeBannerFromStack(name); }, 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'); - } - } + pushBannerToStack(name: BannerName) { + if (this.bannerStack.includes(name)) return; + this.bannerStack.push(name); + }, + removeBannerFromStack(name: BannerName) { + this.bannerStack = this.bannerStack.filter((bannerName) => bannerName !== name); }, - async dismissAllBanners() { - return Promise.all([ - this.dismissBanner('TRIAL', 'temporary'), - this.dismissBanner('TRIAL_OVER', 'temporary'), - this.dismissBanner('V1', 'temporary'), - ]); + clearBannerStack() { + this.bannerStack = []; }, }, }); diff --git a/packages/editor-ui/src/stores/users.store.ts b/packages/editor-ui/src/stores/users.store.ts index 7844e712882a9..dad8670fad228 100644 --- a/packages/editor-ui/src/stores/users.store.ts +++ b/packages/editor-ui/src/stores/users.store.ts @@ -22,6 +22,7 @@ import { } from '@/api/users'; import { PERSONALIZATION_MODAL_KEY, STORES } from '@/constants'; import type { + Cloud, ICredentialsResponse, IInviteResponse, IPersonalizationLatestVersion, @@ -39,6 +40,7 @@ import { useSettingsStore } from './settings.store'; import { useUIStore } from './ui.store'; import { useCloudPlanStore } from './cloudPlan.store'; import { disableMfa, enableMfa, getMfaQR, verifyMfaToken } from '@/api/mfa'; +import { confirmEmail, getCloudUserInfo } from '@/api/cloudPlans'; const isDefaultUser = (user: IUserResponse | null) => Boolean(user && user.isPending && user.globalRole && user.globalRole.name === ROLE.Owner); @@ -52,6 +54,7 @@ export const useUsersStore = defineStore(STORES.USERS, { state: (): IUsersState => ({ currentUserId: null, users: {}, + currentUserCloudInfo: null, }), getters: { allUsers(): IUser[] { @@ -194,7 +197,8 @@ export const useUsersStore = defineStore(STORES.USERS, { this.currentUserId = null; useCloudPlanStore().reset(); usePostHog().reset(); - await useUIStore().dismissAllBanners(); + this.currentUserCloudInfo = null; + useUIStore().clearBannerStack(); }, async createOwner(params: { firstName: string; @@ -365,5 +369,17 @@ export const useUsersStore = defineStore(STORES.USERS, { currentUser.mfaEnabled = false; } }, + async fetchUserCloudAccount() { + let cloudUser: Cloud.UserAccount | null = null; + try { + cloudUser = await getCloudUserInfo(useRootStore().getRestApiContext); + this.currentUserCloudInfo = cloudUser; + } catch (error) { + throw new Error(error); + } + }, + async confirmEmail() { + await confirmEmail(useRootStore().getRestApiContext); + }, }, }); diff --git a/packages/editor-ui/src/views/SigninView.vue b/packages/editor-ui/src/views/SigninView.vue index e1df9a6025da1..b8f10e5b1382d 100644 --- a/packages/editor-ui/src/views/SigninView.vue +++ b/packages/editor-ui/src/views/SigninView.vue @@ -127,7 +127,7 @@ export default defineComponent({ }); this.loading = false; await this.cloudPlanStore.checkForCloudPlanData(); - await this.uiStore.initBanners(); + await this.settingsStore.getSettings(); this.clearAllStickyNotifications(); this.checkRecoveryCodesLeft(); diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index a1b505833eb6d..bda4f19f389a6 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2225,4 +2225,9 @@ export interface SecretsHelpersBase { listSecrets(provider: string): string[]; } -export type BannerName = 'V1' | 'TRIAL_OVER' | 'TRIAL' | 'NON_PRODUCTION_LICENSE'; +export type BannerName = + | 'V1' + | 'TRIAL_OVER' + | 'TRIAL' + | 'NON_PRODUCTION_LICENSE' + | 'EMAIL_CONFIRMATION';