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](f9f122d46d/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](b80d2e3bec/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
This commit is contained in:
Milorad FIlipović 2023-09-21 09:47:21 +02:00 committed by GitHub
parent 2491ccf4d9
commit b0e98b59a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 424 additions and 118 deletions

View file

@ -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;

View file

@ -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;
};
};

View file

@ -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<Cloud.PlanData> {
return get(context.baseUrl, '/admin/cloud-plan');
@ -8,3 +8,11 @@ export async function getCurrentPlan(context: IRestApiContext): Promise<Cloud.Pl
export async function getCurrentUsage(context: IRestApiContext): Promise<InstanceUsage> {
return get(context.baseUrl, '/cloud/limits');
}
export async function getCloudUserInfo(context: IRestApiContext): Promise<Cloud.UserAccount> {
return get(context.baseUrl, '/cloud/proxy/admin/user/me');
}
export async function confirmEmail(context: IRestApiContext): Promise<Cloud.UserAccount> {
return post(context.baseUrl, '/cloud/proxy/admin/user/resend-confirmation-email');
}

View file

@ -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 {

View file

@ -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<typeof useUIStore>;
let usersStore: ReturnType<typeof useUsersStore>;
@ -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();
it('should render banner with the highest priority', async () => {
const { getByTestId, queryByTestId } = 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 },
},
},
}),
}),
});
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();
});
});

View file

@ -1,38 +1,59 @@
<script setup lang="ts">
<script lang="ts">
import NonProductionLicenseBanner from '@/components/banners/NonProductionLicenseBanner.vue';
import TrialOverBanner from '@/components/banners/TrialOverBanner.vue';
import TrialBanner from '@/components/banners/TrialBanner.vue';
import V1Banner from '@/components/banners/V1Banner.vue';
import EmailConfirmationBanner from '@/components/banners/EmailConfirmationBanner.vue';
import type { Component } from 'vue';
import type { N8nBanners } from '@/Interface';
// All banners that can be shown in the app should be registered here.
// This component renders the banner with the highest priority from the banner stack, located in the UI store.
// When registering a new banner, please consult this document to determine it's priority:
// https://www.notion.so/n8n/Banner-stack-60948c4167c743718fde80d6745258d5
export const N8N_BANNERS: N8nBanners = {
V1: { priority: 350, component: V1Banner as Component },
TRIAL_OVER: { priority: 260, component: TrialOverBanner as Component },
EMAIL_CONFIRMATION: { priority: 250, component: EmailConfirmationBanner as Component },
TRIAL: { priority: 150, component: TrialBanner as Component },
NON_PRODUCTION_LICENSE: { priority: 140, component: NonProductionLicenseBanner as Component },
};
</script>
<script setup lang="ts">
import { useUIStore } from '@/stores/ui.store';
import { onMounted, watch } from 'vue';
import { computed, onMounted } from 'vue';
import { getBannerRowHeight } from '@/utils';
import type { BannerName } from 'n8n-workflow';
const uiStore = useUIStore();
function shouldShowBanner(bannerName: BannerName) {
return uiStore.banners[bannerName].dismissed === false;
}
async function updateCurrentBannerHeight() {
const bannerHeight = await getBannerRowHeight();
uiStore.updateBannersHeight(bannerHeight);
}
onMounted(async () => {
await updateCurrentBannerHeight();
const currentlyShownBanner = computed(() => {
void updateCurrentBannerHeight();
if (uiStore.bannerStack.length === 0) return null;
// Find the banner with the highest priority
let banner = N8N_BANNERS[uiStore.bannerStack[0]];
uiStore.bannerStack.forEach((bannerName, index) => {
if (index === 0) return;
const bannerToCompare = N8N_BANNERS[bannerName];
if (bannerToCompare.priority > banner.priority) {
banner = bannerToCompare;
}
});
return banner.component;
});
watch(uiStore.banners, async () => {
onMounted(async () => {
await updateCurrentBannerHeight();
});
</script>
<template>
<div data-test-id="banner-stack">
<trial-over-banner v-if="shouldShowBanner('TRIAL_OVER')" />
<trial-banner v-if="shouldShowBanner('TRIAL')" />
<v1-banner v-if="shouldShowBanner('V1')" />
<non-production-license-banner v-if="shouldShowBanner('NON_PRODUCTION_LICENSE')" />
<component :is="currentlyShownBanner" />
</div>
</template>

View file

@ -1,5 +1,6 @@
<script lang="ts" setup>
import { useUIStore } from '@/stores/ui.store';
import { computed, useSlots } from 'vue';
import type { BannerName } from 'n8n-workflow';
interface Props {
@ -10,6 +11,7 @@ interface Props {
}
const uiStore = useUIStore();
const slots = useSlots();
const props = withDefaults(defineProps<Props>(), {
theme: 'info',
@ -18,6 +20,10 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['close']);
const hasTrailingContent = computed(() => {
return !!slots.trailingContent;
});
async function onCloseClick() {
await uiStore.dismissBanner(props.name);
emit('close');
@ -31,7 +37,7 @@ async function onCloseClick() {
:roundCorners="false"
:data-test-id="`banners-${props.name}`"
>
<div :class="$style.mainContent">
<div :class="[$style.mainContent, !hasTrailingContent ? $style.keepSpace : '']">
<slot name="mainContent" />
</div>
<template #trailingContent>
@ -56,6 +62,10 @@ async function onCloseClick() {
display: flex;
gap: var(--spacing-4xs);
}
.keepSpace {
padding: 5px 0;
}
.trailingContent {
display: flex;
align-items: center;

View file

@ -0,0 +1,54 @@
<script lang="ts" setup>
import BaseBanner from '@/components/banners/BaseBanner.vue';
import { useToast } from '@/composables';
import { i18n as locale } from '@/plugins/i18n';
import { useUsersStore } from '@/stores/users.store';
import { computed } from 'vue';
const toast = useToast();
const userEmail = computed(() => {
const { currentUserCloudInfo } = useUsersStore();
return currentUserCloudInfo?.email ?? '';
});
async function onConfirmEmailClick() {
try {
await useUsersStore().confirmEmail();
toast.showMessage({
type: 'success',
title: locale.baseText('banners.confirmEmail.toast.success.heading'),
message: locale.baseText('banners.confirmEmail.toast.success.message'),
});
} catch (error) {
toast.showMessage({
type: 'error',
title: locale.baseText('banners.confirmEmail.toast.error.heading'),
message: error.message,
});
}
}
</script>
<template>
<base-banner name="EMAIL_CONFIRMATION" theme="warning">
<template #mainContent>
<span>
{{ locale.baseText('banners.confirmEmail.message.1') }}
<router-link to="/settings/personal">{{ userEmail }}</router-link>
{{ locale.baseText('banners.confirmEmail.message.2') }}
</span>
</template>
<template #trailingContent>
<n8n-button
type="success"
@click="onConfirmEmailClick"
icon="envelope"
size="small"
data-test-id="confirm-email-button"
>
{{ locale.baseText('banners.confirmEmail.button') }}
</n8n-button>
</template>
</base-banner>
</template>

View file

@ -515,6 +515,7 @@ export const enum STORES {
NODE_CREATOR = 'nodeCreator',
WEBHOOKS = 'webhooks',
HISTORY = 'history',
CLOUD_PLAN = 'cloudPlan',
}
export const enum SignInType {

View file

@ -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.",

View file

@ -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<typeof useUIStore>;
let settingsStore: ReturnType<typeof useSettingsStore>;
let rootStore: ReturnType<typeof useRootStore>;
let cloudPlanStore: ReturnType<typeof useCloudPlanStore>;
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');
});
});

View file

@ -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);
}

View file

@ -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);

View file

@ -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<void> {
@ -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 {

View file

@ -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<void> {
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);
},
async dismissAllBanners() {
return Promise.all([
this.dismissBanner('TRIAL', 'temporary'),
this.dismissBanner('TRIAL_OVER', 'temporary'),
this.dismissBanner('V1', 'temporary'),
]);
removeBannerFromStack(name: BannerName) {
this.bannerStack = this.bannerStack.filter((bannerName) => bannerName !== name);
},
clearBannerStack() {
this.bannerStack = [];
},
},
});

View file

@ -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);
},
},
});

View file

@ -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();

View file

@ -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';