import * as onboardingApi from '@/api/workflow-webhooks'; import { ABOUT_MODAL_KEY, CHAT_EMBED_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_EDIT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DELETE_USER_MODAL_KEY, DUPLICATE_MODAL_KEY, FAKE_DOOR_FEATURES, IMPORT_CURL_MODAL_KEY, INVITE_USER_MODAL_KEY, LOG_STREAM_MODAL_KEY, MFA_SETUP_MODAL_KEY, PERSONALIZATION_MODAL_KEY, STORES, TAGS_MANAGER_MODAL_KEY, NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS, WORKFLOW_ACTIVE_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY, EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PULL_MODAL_KEY, DEBUG_PAYWALL_MODAL_KEY, N8N_PRICING_PAGE_URL, WORKFLOW_HISTORY_VERSION_RESTORE, SETUP_CREDENTIALS_MODAL_KEY, PROJECT_MOVE_RESOURCE_MODAL, PROJECT_MOVE_RESOURCE_CONFIRM_MODAL, } from '@/constants'; import type { CloudUpdateLinkSourceType, IFakeDoorLocation, INodeUi, UTMCampaign, XYPosition, Modals, NewCredentialsModal, ThemeOption, NotificationOptions, ModalState, ModalKey, IFakeDoor, } from '@/Interface'; import { defineStore } from 'pinia'; import { useRootStore } from '@/stores/root.store'; import * as curlParserApi from '@/api/curlHelper'; import { useCloudPlanStore } from '@/stores/cloudPlan.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useSettingsStore } from '@/stores/settings.store'; import { hasPermission } from '@/utils/rbac/permissions'; import { useTelemetryStore } from '@/stores/telemetry.store'; import { useUsersStore } from '@/stores/users.store'; import { dismissBannerPermanently } from '@/api/ui'; import type { BannerName } from 'n8n-workflow'; import { addThemeToBody, getPreferredTheme, getThemeOverride, isValidTheme, updateTheme, } from './ui.utils'; import { computed, ref } from 'vue'; import type { Connection } from '@vue-flow/core'; let savedTheme: ThemeOption = 'system'; try { const value = getThemeOverride(); if (isValidTheme(value)) { savedTheme = value; addThemeToBody(value); } } catch (e) {} type UiStore = ReturnType; type Draggable = { isDragging: boolean; type: string; data: string; canDrop: boolean; stickyPosition: null | XYPosition; }; export const useUIStore = defineStore(STORES.UI, () => { const activeActions = ref([]); const activeCredentialType = ref(null); const theme = ref(savedTheme); const modalsById = ref>({ ...Object.fromEntries( [ ABOUT_MODAL_KEY, CHAT_EMBED_MODAL_KEY, CHANGE_PASSWORD_MODAL_KEY, CONTACT_PROMPT_MODAL_KEY, CREDENTIAL_SELECT_MODAL_KEY, DUPLICATE_MODAL_KEY, PERSONALIZATION_MODAL_KEY, INVITE_USER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_ACTIVE_MODAL_KEY, COMMUNITY_PACKAGE_INSTALL_MODAL_KEY, MFA_SETUP_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PULL_MODAL_KEY, EXTERNAL_SECRETS_PROVIDER_MODAL_KEY, DEBUG_PAYWALL_MODAL_KEY, WORKFLOW_HISTORY_VERSION_RESTORE, SETUP_CREDENTIALS_MODAL_KEY, PROJECT_MOVE_RESOURCE_MODAL, PROJECT_MOVE_RESOURCE_CONFIRM_MODAL, ].map((modalKey) => [modalKey, { open: false }]), ), [DELETE_USER_MODAL_KEY]: { open: false, activeId: null, }, [COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY]: { open: false, mode: '', activeId: null, }, [IMPORT_CURL_MODAL_KEY]: { open: false, data: { curlCommand: '', }, }, [LOG_STREAM_MODAL_KEY]: { open: false, data: undefined, }, [CREDENTIAL_EDIT_MODAL_KEY]: { open: false, mode: '', activeId: null, showAuthSelector: false, } as ModalState, }); const modalStack = ref([]); const sidebarMenuCollapsed = ref(true); const currentView = ref(''); const fakeDoorFeatures = ref([ { id: FAKE_DOOR_FEATURES.SSO, featureName: 'fakeDoor.settings.sso.name', icon: 'key', actionBoxTitle: 'fakeDoor.settings.sso.actionBox.title', actionBoxDescription: 'fakeDoor.settings.sso.actionBox.description', linkURL: 'https://n8n-community.typeform.com/to/l7QOrERN#f=sso', uiLocations: ['settings/users'], }, ]); const draggable = ref({ isDragging: false, type: '', data: '', canDrop: false, stickyPosition: null, }); const stateIsDirty = ref(false); const lastSelectedNode = ref(null); const lastSelectedNodeOutputIndex = ref(null); const lastSelectedNodeEndpointUuid = ref(null); const lastSelectedNodeConnection = ref(null); const nodeViewOffsetPosition = ref<[number, number]>([0, 0]); const nodeViewMoveInProgress = ref(false); const selectedNodes = ref([]); const nodeViewInitialized = ref(false); const addFirstStepOnLoad = ref(false); const bannersHeight = ref(0); const bannerStack = ref([]); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const isCreateNodeActive = ref(false); const settingsStore = useSettingsStore(); const workflowsStore = useWorkflowsStore(); const rootStore = useRootStore(); const telemetryStore = useTelemetryStore(); const cloudPlanStore = useCloudPlanStore(); const userStore = useUsersStore(); const appliedTheme = computed(() => { return theme.value === 'system' ? getPreferredTheme() : theme.value; }); const logo = computed(() => { const { releaseChannel } = settingsStore.settings; const suffix = appliedTheme.value === 'dark' ? '-dark.svg' : '.svg'; return `static/logo/${ releaseChannel === 'stable' ? 'expanded' : `channel/${releaseChannel}` }${suffix}`; }); const contextBasedTranslationKeys = computed(() => { const deploymentType = settingsStore.deploymentType; let contextKey: '' | '.cloud' | '.desktop' = ''; if (deploymentType === 'cloud') { contextKey = '.cloud'; } else if (deploymentType === 'desktop_mac' || deploymentType === 'desktop_win') { contextKey = '.desktop'; } return { feature: { unavailable: { title: `contextual.feature.unavailable.title${contextKey}`, }, }, credentials: { sharing: { unavailable: { title: `contextual.credentials.sharing.unavailable.title${contextKey}`, description: `contextual.credentials.sharing.unavailable.description${contextKey}`, action: `contextual.credentials.sharing.unavailable.action${contextKey}`, button: `contextual.credentials.sharing.unavailable.button${contextKey}`, }, }, }, workflows: { sharing: { title: 'contextual.workflows.sharing.title', unavailable: { title: `contextual.workflows.sharing.unavailable.title${contextKey}`, description: { modal: `contextual.workflows.sharing.unavailable.description.modal${contextKey}`, tooltip: `contextual.workflows.sharing.unavailable.description.tooltip${contextKey}`, }, action: `contextual.workflows.sharing.unavailable.action${contextKey}`, button: `contextual.workflows.sharing.unavailable.button${contextKey}`, }, }, }, variables: { unavailable: { title: `contextual.variables.unavailable.title${contextKey}`, description: 'contextual.variables.unavailable.description', action: `contextual.variables.unavailable.action${contextKey}`, button: `contextual.variables.unavailable.button${contextKey}`, }, }, users: { settings: { unavailable: { title: `contextual.users.settings.unavailable.title${contextKey}`, description: `contextual.users.settings.unavailable.description${contextKey}`, button: `contextual.users.settings.unavailable.button${contextKey}`, }, }, }, } as const; }); const getLastSelectedNode = computed(() => { if (lastSelectedNode.value) { return workflowsStore.getNodeByName(lastSelectedNode.value); } return null; }); const isVersionsOpen = computed(() => { return modalsById.value[VERSIONS_MODAL_KEY].open; }); const isModalActiveById = computed(() => Object.keys(modalsById.value).reduce((acc: { [key: string]: boolean }, name) => { acc[name] = name === modalStack.value[0]; return acc; }, {}), ); const fakeDoorsByLocation = computed(() => fakeDoorFeatures.value.reduce((acc: { [uiLocation: string]: IFakeDoor }, fakeDoor) => { fakeDoor.uiLocations.forEach((uiLocation: IFakeDoorLocation) => { acc[uiLocation] = fakeDoor; }); return acc; }, {}), ); const fakeDoorsById = computed(() => fakeDoorFeatures.value.reduce((acc: { [id: string]: IFakeDoor }, fakeDoor) => { acc[fakeDoor.id.toString()] = fakeDoor; return acc; }, {}), ); const isReadOnlyView = computed(() => { return ![ VIEWS.WORKFLOW.toString(), VIEWS.NEW_WORKFLOW.toString(), VIEWS.EXECUTION_DEBUG.toString(), ].includes(currentView.value); }); const isActionActive = computed(() => activeActions.value.reduce((acc: { [action: string]: boolean }, action) => { acc[action] = true; return acc; }, {}), ); const getSelectedNodes = computed(() => { const seen = new Set(); return selectedNodes.value.filter((node) => { // dedupe for instances when same node is selected in different ways if (!seen.has(node)) { seen.add(node); return true; } return false; }); }); const isNodeSelected = computed(() => selectedNodes.value.reduce((acc: { [nodeName: string]: true }, node) => { acc[node.name] = true; return acc; }, {}), ); const headerHeight = computed(() => { const style = getComputedStyle(document.body); return Number(style.getPropertyValue('--header-height')); }); const isAnyModalOpen = computed(() => { return modalStack.value.length > 0; }); // Methods const setTheme = (newTheme: ThemeOption): void => { theme.value = newTheme; updateTheme(newTheme); }; const setMode = (name: keyof Modals, mode: string): void => { modalsById.value[name] = { ...modalsById.value[name], mode, }; }; const setActiveId = (name: keyof Modals, activeId: string | null): void => { modalsById.value[name] = { ...modalsById.value[name], activeId, }; }; const setShowAuthSelector = (name: keyof Modals, showAuthSelector: boolean): void => { modalsById.value[name] = { ...modalsById.value[name], showAuthSelector, } as NewCredentialsModal; }; const setModalData = (payload: { name: keyof Modals; data: Record }) => { modalsById.value[payload.name] = { ...modalsById.value[payload.name], data: payload.data, }; }; const openModal = (name: ModalKey) => { modalsById.value[name] = { ...modalsById.value[name], open: true, }; modalStack.value = [name].concat(modalStack.value) as string[]; }; const openModalWithData = (payload: { name: ModalKey; data: Record }) => { setModalData(payload); openModal(payload.name); }; const closeModal = (name: ModalKey) => { modalsById.value[name] = { ...modalsById.value[name], open: false, }; modalStack.value = modalStack.value.filter((openModalName) => name !== openModalName); }; const draggableStartDragging = (type: string, data: string) => { draggable.value = { isDragging: true, type, data, canDrop: false, stickyPosition: null, }; }; const draggableStopDragging = () => { draggable.value = { isDragging: false, type: '', data: '', canDrop: false, stickyPosition: null, }; }; const setDraggableStickyPos = (position: XYPosition) => { draggable.value = { ...draggable.value, stickyPosition: position, }; }; const setDraggableCanDrop = (canDrop: boolean) => { draggable.value = { ...draggable.value, canDrop, }; }; const openDeleteUserModal = (id: string) => { setActiveId(DELETE_USER_MODAL_KEY, id); openModal(DELETE_USER_MODAL_KEY); }; const openExistingCredential = (id: string) => { setActiveId(CREDENTIAL_EDIT_MODAL_KEY, id); setMode(CREDENTIAL_EDIT_MODAL_KEY, 'edit'); openModal(CREDENTIAL_EDIT_MODAL_KEY); }; const openNewCredential = (type: string, showAuthOptions = false) => { setActiveId(CREDENTIAL_EDIT_MODAL_KEY, type); setShowAuthSelector(CREDENTIAL_EDIT_MODAL_KEY, showAuthOptions); setMode(CREDENTIAL_EDIT_MODAL_KEY, 'new'); openModal(CREDENTIAL_EDIT_MODAL_KEY); }; const submitContactEmail = async (email: string, agree: boolean) => { const instanceId = rootStore.instanceId; const { currentUser } = userStore; if (currentUser) { return await onboardingApi.submitEmailOnSignup( instanceId, currentUser, email ?? currentUser.email, agree, ); } return null; }; const openCommunityPackageUninstallConfirmModal = (packageName: string) => { setMode(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, packageName); setActiveId(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS.UNINSTALL); openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY); }; const openCommunityPackageUpdateConfirmModal = (packageName: string) => { setMode(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, packageName); setActiveId(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY, COMMUNITY_PACKAGE_MANAGE_ACTIONS.UPDATE); openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY); }; const addActiveAction = (action: string) => { if (!activeActions.value.includes(action)) { activeActions.value.push(action); } }; const removeActiveAction = (action: string) => { const actionIndex = activeActions.value.indexOf(action); if (actionIndex !== -1) { activeActions.value.splice(actionIndex, 1); } }; const addSelectedNode = (node: INodeUi) => { const isAlreadySelected = selectedNodes.value.some((n) => n.name === node.name); if (!isAlreadySelected) { selectedNodes.value.push(node); } }; const removeNodeFromSelection = (node: INodeUi) => { for (const [index] of selectedNodes.value.entries()) { if (selectedNodes.value[index].name === node.name) { selectedNodes.value.splice(Number(index), 1); break; } } }; const resetSelectedNodes = () => { selectedNodes.value = []; }; const setCurlCommand = (payload: { name: string; command: string }) => { modalsById.value[payload.name] = { ...modalsById.value[payload.name], curlCommand: payload.command, }; }; const toggleSidebarMenuCollapse = () => { sidebarMenuCollapsed.value = !sidebarMenuCollapsed.value; }; const getCurlToJson = async (curlCommand: string) => { const parameters = await curlParserApi.getCurlToJson(rootStore.restApiContext, curlCommand); // Normalize placeholder values if (parameters['parameters.url']) { parameters['parameters.url'] = parameters['parameters.url'] .replaceAll('%7B', '{') .replaceAll('%7D', '}'); } return parameters; }; const goToUpgrade = async ( source: CloudUpdateLinkSourceType, utm_campaign: UTMCampaign, mode: 'open' | 'redirect' = 'open', ) => { const { usageLeft, trialDaysLeft, userIsTrialing } = cloudPlanStore; const { executionsLeft, workflowsLeft } = usageLeft; const deploymentType = settingsStore.deploymentType; telemetryStore.track('User clicked upgrade CTA', { source, isTrial: userIsTrialing, deploymentType, trialDaysLeft, executionsLeft, workflowsLeft, }); const upgradeLink = await generateUpgradeLinkUrl(source, utm_campaign, deploymentType); if (mode === 'open') { window.open(upgradeLink, '_blank'); } else { location.href = upgradeLink; } }; const removeBannerFromStack = (name: BannerName) => { bannerStack.value = bannerStack.value.filter((bannerName) => bannerName !== name); }; const dismissBanner = async (name: BannerName, type: 'temporary' | 'permanent' = 'temporary') => { if (type === 'permanent') { await dismissBannerPermanently(rootStore.restApiContext, { bannerName: name, dismissedBanners: settingsStore.permanentlyDismissedBanners, }); removeBannerFromStack(name); return; } removeBannerFromStack(name); }; const updateBannersHeight = (newHeight: number) => { bannersHeight.value = newHeight; }; const pushBannerToStack = (name: BannerName) => { if (bannerStack.value.includes(name)) return; bannerStack.value.push(name); }; const clearBannerStack = () => { bannerStack.value = []; }; const setNotificationsForView = (view: VIEWS, notifications: NotificationOptions[]) => { pendingNotificationsForViews.value[view] = notifications; }; const deleteNotificationsForView = (view: VIEWS) => { delete pendingNotificationsForViews.value[view]; }; return { appliedTheme, logo, contextBasedTranslationKeys, getLastSelectedNode, isVersionsOpen, isModalActiveById, fakeDoorsByLocation, isReadOnlyView, isActionActive, activeActions, getSelectedNodes, isNodeSelected, headerHeight, stateIsDirty, lastSelectedNodeOutputIndex, activeCredentialType, lastSelectedNode, selectedNodes, bannersHeight, lastSelectedNodeEndpointUuid, lastSelectedNodeConnection, nodeViewOffsetPosition, nodeViewMoveInProgress, nodeViewInitialized, addFirstStepOnLoad, isCreateNodeActive, sidebarMenuCollapsed, fakeDoorFeatures, bannerStack, theme, modalsById, currentView, isAnyModalOpen, fakeDoorsById, pendingNotificationsForViews, setTheme, setMode, setActiveId, setShowAuthSelector, setModalData, openModalWithData, openModal, closeModal, draggableStartDragging, draggableStopDragging, setDraggableStickyPos, setDraggableCanDrop, openDeleteUserModal, openExistingCredential, openNewCredential, submitContactEmail, openCommunityPackageUninstallConfirmModal, openCommunityPackageUpdateConfirmModal, addActiveAction, removeActiveAction, addSelectedNode, removeNodeFromSelection, resetSelectedNodes, setCurlCommand, toggleSidebarMenuCollapse, getCurlToJson, goToUpgrade, removeBannerFromStack, dismissBanner, updateBannersHeight, pushBannerToStack, clearBannerStack, setNotificationsForView, deleteNotificationsForView, }; }); /** * Helper function for listening to credential changes in the store */ export const listenForModalChanges = (opts: { store: UiStore; onModalOpened?: (name: keyof Modals) => void; onModalClosed?: (name: keyof Modals) => void; }) => { const { store, onModalClosed, onModalOpened } = opts; const listeningForActions = ['openModal', 'openModalWithData', 'closeModal']; return store.$onAction((result) => { const { name, after, args } = result; after(async () => { if (!listeningForActions.includes(name)) { return; } switch (name) { case 'openModal': { const modalName = args[0]; onModalOpened?.(modalName); break; } case 'openModalWithData': { const { name: modalName } = args[0] ?? {}; onModalOpened?.(modalName); break; } case 'closeModal': { const modalName = args[0]; onModalClosed?.(modalName); break; } } }); }); }; export const generateUpgradeLinkUrl = async ( source: string, utm_campaign: string, deploymentType: string, ) => { let linkUrl = ''; const searchParams = new URLSearchParams(); const cloudPlanStore = useCloudPlanStore(); if (deploymentType === 'cloud' && hasPermission(['instanceOwner'])) { const adminPanelHost = new URL(window.location.href).host.split('.').slice(1).join('.'); const { code } = await cloudPlanStore.getAutoLoginCode(); linkUrl = `https://${adminPanelHost}/login`; searchParams.set('code', code); searchParams.set('returnPath', '/account/change-plan'); } else { linkUrl = N8N_PRICING_PAGE_URL; } if (utm_campaign) { searchParams.set('utm_campaign', utm_campaign); } if (source) { searchParams.set('source', source); } return `${linkUrl}?${searchParams.toString()}`; };