feat(editor): Improve performance by importing routes dynamically and add route guards (no-changelog) (#7567)

**Before:**
<img width="657" alt="image"
src="https://github.com/n8n-io/n8n/assets/6179477/0bcced2b-9d3a-43b3-80d7-3c72619941fa">


**After:**
<img width="660" alt="image"
src="https://github.com/n8n-io/n8n/assets/6179477/e74e0bbf-bf33-49b4-ae11-65f640405ac8">
This commit is contained in:
Alex Grozav
2023-11-03 16:22:37 +02:00
committed by GitHub
parent c92402a3ca
commit 24dfc95974
21 changed files with 387 additions and 294 deletions

View File

@@ -15,7 +15,7 @@
<div id="header" :class="$style.header">
<router-view name="header"></router-view>
</div>
<div id="sidebar" :class="$style.sidebar">
<div v-if="usersStore.currentUser" id="sidebar" :class="$style.sidebar">
<router-view name="sidebar"></router-view>
</div>
<div id="content" :class="$style.content">
@@ -44,7 +44,7 @@ import { HIRING_BANNER, VIEWS } from '@/constants';
import { userHelpers } from '@/mixins/userHelpers';
import { loadLanguage } from '@/plugins/i18n';
import { useGlobalLinkActions, useTitleChange, useToast, useExternalHooks } from '@/composables';
import { useGlobalLinkActions, useToast, useExternalHooks } from '@/composables';
import {
useUIStore,
useSettingsStore,
@@ -59,7 +59,6 @@ import {
import { useHistoryHelper } from '@/composables/useHistoryHelper';
import { newVersions } from '@/mixins/newVersions';
import { useRoute } from 'vue-router';
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
export default defineComponent({
name: 'App',
@@ -101,118 +100,39 @@ export default defineComponent({
},
data() {
return {
postAuthenticateDone: false,
settingsInitialized: false,
onAfterAuthenticateInitialized: false,
loading: true,
};
},
methods: {
async initSettings(): Promise<void> {
// The settings should only be initialized once
if (this.settingsInitialized) return;
try {
await this.settingsStore.getSettings();
this.settingsInitialized = true;
// Re-compute title since settings are now available
useTitleChange().titleReset();
} catch (e) {
this.showToast({
title: this.$locale.baseText('startupError'),
message: this.$locale.baseText('startupError.message'),
type: 'error',
duration: 0,
dangerouslyUseHTMLString: true,
});
throw e;
}
},
async loginWithCookie(): Promise<void> {
try {
await this.usersStore.loginWithCookie();
} catch (e) {}
},
async initTemplates(): Promise<void> {
if (!this.settingsStore.isTemplatesEnabled) {
return;
}
try {
await this.settingsStore.testTemplatesEndpoint();
} catch (e) {}
},
logHiringBanner() {
if (this.settingsStore.isHiringBannerEnabled && !this.isDemoMode) {
console.log(HIRING_BANNER);
}
},
async checkForCloudData() {
async initializeCloudData() {
await this.cloudPlanStore.checkForCloudPlanData();
await this.cloudPlanStore.fetchUserCloudAccount();
},
async initialize(): Promise<void> {
await this.initSettings();
ExpressionEvaluatorProxy.setEvaluator(useSettingsStore().settings.expressions.evaluator);
await Promise.all([this.loginWithCookie(), this.initTemplates()]);
async initializeTemplates() {
if (this.settingsStore.isTemplatesEnabled) {
try {
await this.settingsStore.testTemplatesEndpoint();
} catch (e) {}
}
},
trackPage(): void {
this.uiStore.currentView = this.$route.name || '';
if (this.$route?.meta?.templatesEnabled) {
this.templatesStore.setSessionId();
} else {
this.templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages
async initializeSourceControl() {
if (this.sourceControlStore.isEnterpriseSourceControlEnabled) {
await this.sourceControlStore.getPreferences();
}
this.$telemetry.page(this.$route);
},
async authenticate() {
// redirect to setup page. user should be redirected to this only once
if (this.settingsStore.showSetupPage) {
if (this.$route.name === VIEWS.SETUP) {
return;
}
return this.$router.replace({ name: VIEWS.SETUP });
async initializeNodeTranslationHeaders() {
if (this.defaultLocale !== 'en') {
await this.nodeTypesStore.getNodeTranslationHeaders();
}
if (this.canUserAccessCurrentRoute()) {
return;
}
// if cannot access page and not logged in, ask to sign in
const user = this.usersStore.currentUser;
if (!user) {
const redirect =
this.$route.query.redirect ||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
return this.$router.replace({ name: VIEWS.SIGNIN, query: { redirect } });
}
// if cannot access page and is logged in, respect signin redirect
if (this.$route.name === VIEWS.SIGNIN && typeof this.$route.query.redirect === 'string') {
const redirect = decodeURIComponent(this.$route.query.redirect);
if (redirect.startsWith('/')) {
// protect against phishing
return this.$router.replace(redirect);
}
}
// if cannot access page and is logged in
return this.$router.replace({ name: VIEWS.HOMEPAGE });
},
async redirectIfNecessary() {
const redirect =
this.$route.meta &&
typeof this.$route.meta.getRedirect === 'function' &&
this.$route.meta.getRedirect();
if (redirect) {
return this.$router.replace(redirect);
}
return;
},
async postAuthenticate() {
if (this.postAuthenticateDone) {
async onAfterAuthenticate() {
if (this.onAfterAuthenticateInitialized) {
return;
}
@@ -220,43 +140,31 @@ export default defineComponent({
return;
}
if (this.sourceControlStore.isEnterpriseSourceControlEnabled) {
await this.sourceControlStore.getPreferences();
}
await Promise.all([
this.initializeCloudData(),
this.initializeSourceControl(),
this.initializeTemplates(),
this.initializeNodeTranslationHeaders(),
]);
this.postAuthenticateDone = true;
this.onAfterAuthenticateInitialized = true;
},
},
async created() {
await this.initialize();
async mounted() {
this.logHiringBanner();
await this.authenticate();
await this.redirectIfNecessary();
void this.checkForNewVersions();
await this.checkForCloudData();
void this.postAuthenticate();
void this.onAfterAuthenticate();
this.loading = false;
this.trackPage();
void this.externalHooks.run('app.mount');
if (this.defaultLocale !== 'en') {
await this.nodeTypesStore.getNodeTranslationHeaders();
}
this.loading = false;
},
watch: {
'usersStore.currentUser'(currentValue, previousValue) {
async 'usersStore.currentUser'(currentValue, previousValue) {
if (currentValue && !previousValue) {
void this.postAuthenticate();
await this.onAfterAuthenticate();
}
},
async $route() {
await this.initSettings();
await this.redirectIfNecessary();
this.trackPage();
},
defaultLocale(newLocale) {
void loadLanguage(newLocale);
},

View File

@@ -1168,6 +1168,7 @@ export interface INodeCreatorState {
}
export interface ISettingsState {
initialized: boolean;
settings: IN8nUISettings;
promptsData: IN8nPrompts;
userManagement: IUserManagementSettings;
@@ -1232,6 +1233,7 @@ export interface IVersionsState {
}
export interface IUsersState {
initialized: boolean;
currentUserId: null | string;
users: { [userId: string]: IUser };
currentUserCloudInfo: Cloud.UserAccount | null;

View File

@@ -2,6 +2,8 @@ import { createPinia, setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import router from '@/router';
import { VIEWS } from '@/constants';
import { setupServer } from '@/__tests__/server';
import { useSettingsStore } from '@/stores';
const App = {
template: '<div />',
@@ -9,25 +11,66 @@ const App = {
const renderComponent = createComponentRenderer(App);
describe('router', () => {
beforeAll(() => {
let server: ReturnType<typeof setupServer>;
beforeAll(async () => {
server = setupServer();
const pinia = createPinia();
setActivePinia(pinia);
renderComponent({ pinia });
});
afterAll(() => {
server.shutdown();
});
test.each([
['/', VIEWS.WORKFLOWS],
['/workflow', VIEWS.NEW_WORKFLOW],
['/workflow/new', VIEWS.NEW_WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw', VIEWS.WORKFLOW],
['/workflow/R9JFXwkUCL1jZBuw/executions/29021', VIEWS.EXECUTION_PREVIEW],
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT],
['/workflows/demo', VIEWS.DEMO],
])(
'should resolve %s to %s',
async (path, name) => {
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
test.each([
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.WORKFLOWS],
['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOWS],
['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOWS],
])(
'should redirect %s to %s if user does not have permissions',
async (path, name) => {
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
test.each([
['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG],
['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY],
['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY],
])('should resolve %s to %s', async (path, name) => {
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
});
])(
'should resolve %s to %s if user has permissions',
async (path, name) => {
const settingsStore = useSettingsStore();
await settingsStore.getSettings();
settingsStore.settings.enterprise.debugInEditor = true;
settingsStore.settings.enterprise.workflowHistory = true;
await router.push(path);
expect(router.currentRoute.value.name).toBe(name);
},
10000,
);
});

View File

@@ -14,12 +14,18 @@ const defaultSettings: IN8nUISettings = {
ldap: false,
saml: false,
logStreaming: false,
debugInEditor: false,
advancedExecutionFilters: false,
variables: true,
sourceControl: false,
auditLogs: false,
versionControl: false,
showNonProdBanner: false,
externalSecrets: false,
binaryDataS3: false,
workflowHistory: false,
},
expressions: {
evaluator: 'tournament',
},
executionMode: 'regular',
executionTimeout: 0,
@@ -43,6 +49,7 @@ const defaultSettings: IN8nUISettings = {
},
publicApi: { enabled: false, latestVersion: 0, path: '', swaggerUi: { enabled: false } },
pushBackend: 'websocket',
releaseChannel: 'stable',
saveDataErrorExecution: 'DEFAULT',
saveDataSuccessExecution: 'DEFAULT',
saveManualExecutions: false,
@@ -58,7 +65,7 @@ const defaultSettings: IN8nUISettings = {
urlBaseEditor: '',
urlBaseWebhook: '',
userManagement: {
showSetupOnFirstLoad: true,
showSetupOnFirstLoad: false,
smtpSetup: true,
authenticationMethod: 'email',
},

View File

@@ -37,7 +37,6 @@ export default defineComponent({
max-width: 1280px;
justify-content: center;
box-sizing: border-box;
background: var(--color-gray-light);
padding: var(--spacing-l) var(--spacing-l) 0;
@media (min-width: 1200px) {
padding: var(--spacing-2xl) var(--spacing-2xl) 0;

View File

@@ -20,9 +20,7 @@ import { GlobalComponentsPlugin } from './plugins/components';
import { GlobalDirectivesPlugin } from './plugins/directives';
import { FontAwesomePlugin } from './plugins/icons';
import { runExternalHook } from '@/utils';
import { createPinia, PiniaVuePlugin } from 'pinia';
import { useWebhooksStore } from '@/stores';
import { JsPlumbPlugin } from '@/plugins/jsplumb';
const pinia = createPinia();
@@ -42,10 +40,6 @@ app.use(i18nInstance);
app.mount('#app');
router.afterEach((to, from) => {
void runExternalHook('main.routeChange', useWebhooksStore(), { from, to });
});
if (!import.meta.env.PROD) {
// Make sure that we get all error messages properly displayed
// as long as we are not in production mode

View File

@@ -1,49 +1,54 @@
import { useStorage } from '@vueuse/core';
import ChangePasswordView from './views/ChangePasswordView.vue';
import ErrorView from './views/ErrorView.vue';
import ForgotMyPasswordView from './views/ForgotMyPasswordView.vue';
import MainHeader from '@/components/MainHeader/MainHeader.vue';
import MainSidebar from '@/components/MainSidebar.vue';
import NodeView from '@/views/NodeView.vue';
import WorkflowExecutionsList from '@/components/ExecutionsView/ExecutionsList.vue';
import ExecutionsLandingPage from '@/components/ExecutionsView/ExecutionsLandingPage.vue';
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
import SettingsView from './views/SettingsView.vue';
import SettingsLdapView from './views/SettingsLdapView.vue';
import SettingsPersonalView from './views/SettingsPersonalView.vue';
import SettingsUsersView from './views/SettingsUsersView.vue';
import SettingsCommunityNodesView from './views/SettingsCommunityNodesView.vue';
import SettingsApiView from './views/SettingsApiView.vue';
import SettingsLogStreamingView from './views/SettingsLogStreamingView.vue';
import SettingsFakeDoorView from './views/SettingsFakeDoorView.vue';
import SetupView from './views/SetupView.vue';
import SigninView from './views/SigninView.vue';
import SignupView from './views/SignupView.vue';
import type { RouteLocation, RouteRecordRaw } from 'vue-router';
import { createRouter, createWebHistory } from 'vue-router';
import TemplatesCollectionView from '@/views/TemplatesCollectionView.vue';
import TemplatesWorkflowView from '@/views/TemplatesWorkflowView.vue';
import TemplatesSearchView from '@/views/TemplatesSearchView.vue';
import CredentialsView from '@/views/CredentialsView.vue';
import ExecutionsView from '@/views/ExecutionsView.vue';
import WorkflowsView from '@/views/WorkflowsView.vue';
import VariablesView from '@/views/VariablesView.vue';
import type { IPermissions } from './Interface';
import { LOGIN_STATUS, ROLE } from '@/utils';
import { isAuthorized, LOGIN_STATUS, ROLE, runExternalHook } from '@/utils';
import { useSettingsStore } from './stores/settings.store';
import { useUsersStore } from './stores/users.store';
import { useTemplatesStore } from './stores/templates.store';
import { useUIStore } from '@/stores/ui.store';
import { useSSOStore } from './stores/sso.store';
import SettingsUsageAndPlanVue from './views/SettingsUsageAndPlan.vue';
import SettingsSso from './views/SettingsSso.vue';
import SignoutView from '@/views/SignoutView.vue';
import SamlOnboarding from '@/views/SamlOnboarding.vue';
import SettingsSourceControl from './views/SettingsSourceControl.vue';
import SettingsExternalSecrets from './views/SettingsExternalSecrets.vue';
import SettingsAuditLogs from './views/SettingsAuditLogs.vue';
import WorkflowHistory from '@/views/WorkflowHistory.vue';
import WorkflowOnboardingView from '@/views/WorkflowOnboardingView.vue';
import { useWebhooksStore } from '@/stores/webhooks.store';
import { EnterpriseEditionFeature, VIEWS } from '@/constants';
import { useTelemetry } from '@/composables';
const ChangePasswordView = async () => import('./views/ChangePasswordView.vue');
const ErrorView = async () => import('./views/ErrorView.vue');
const ForgotMyPasswordView = async () => import('./views/ForgotMyPasswordView.vue');
const MainHeader = async () => import('@/components/MainHeader/MainHeader.vue');
const MainSidebar = async () => import('@/components/MainSidebar.vue');
const NodeView = async () => import('@/views/NodeView.vue');
const WorkflowExecutionsList = async () => import('@/components/ExecutionsView/ExecutionsList.vue');
const ExecutionsLandingPage = async () =>
import('@/components/ExecutionsView/ExecutionsLandingPage.vue');
const ExecutionPreview = async () => import('@/components/ExecutionsView/ExecutionPreview.vue');
const SettingsView = async () => import('./views/SettingsView.vue');
const SettingsLdapView = async () => import('./views/SettingsLdapView.vue');
const SettingsPersonalView = async () => import('./views/SettingsPersonalView.vue');
const SettingsUsersView = async () => import('./views/SettingsUsersView.vue');
const SettingsCommunityNodesView = async () => import('./views/SettingsCommunityNodesView.vue');
const SettingsApiView = async () => import('./views/SettingsApiView.vue');
const SettingsLogStreamingView = async () => import('./views/SettingsLogStreamingView.vue');
const SettingsFakeDoorView = async () => import('./views/SettingsFakeDoorView.vue');
const SetupView = async () => import('./views/SetupView.vue');
const SigninView = async () => import('./views/SigninView.vue');
const SignupView = async () => import('./views/SignupView.vue');
const TemplatesCollectionView = async () => import('@/views/TemplatesCollectionView.vue');
const TemplatesWorkflowView = async () => import('@/views/TemplatesWorkflowView.vue');
const TemplatesSearchView = async () => import('@/views/TemplatesSearchView.vue');
const CredentialsView = async () => import('@/views/CredentialsView.vue');
const ExecutionsView = async () => import('@/views/ExecutionsView.vue');
const WorkflowsView = async () => import('@/views/WorkflowsView.vue');
const VariablesView = async () => import('@/views/VariablesView.vue');
const SettingsUsageAndPlan = async () => import('./views/SettingsUsageAndPlan.vue');
const SettingsSso = async () => import('./views/SettingsSso.vue');
const SignoutView = async () => import('@/views/SignoutView.vue');
const SamlOnboarding = async () => import('@/views/SamlOnboarding.vue');
const SettingsSourceControl = async () => import('./views/SettingsSourceControl.vue');
const SettingsExternalSecrets = async () => import('./views/SettingsExternalSecrets.vue');
const SettingsAuditLogs = async () => import('./views/SettingsAuditLogs.vue');
const WorkflowHistory = async () => import('@/views/WorkflowHistory.vue');
const WorkflowOnboardingView = async () => import('@/views/WorkflowOnboardingView.vue');
interface IRouteConfig {
meta: {
@@ -521,7 +526,7 @@ export const routes = [
path: 'usage',
name: VIEWS.USAGE,
components: {
settingsView: SettingsUsageAndPlanVue,
settingsView: SettingsUsageAndPlan,
},
meta: {
telemetry: {
@@ -869,4 +874,91 @@ const router = createRouter({
routes,
});
router.beforeEach(async (to, from, next) => {
/**
* Initialize stores before routing
*/
const settingsStore = useSettingsStore();
const usersStore = useUsersStore();
await settingsStore.initialize();
await usersStore.initialize();
/**
* Redirect to setup page. User should be redirected to this only once
*/
if (settingsStore.showSetupPage) {
if (to.name === VIEWS.SETUP) {
return next();
}
return next({ name: VIEWS.SETUP });
}
/**
* Verify user permissions for current route
*/
const currentUser = usersStore.currentUser;
const permissions = to.meta?.permissions as IPermissions;
const canUserAccessCurrentRoute = permissions && isAuthorized(permissions, currentUser);
if (canUserAccessCurrentRoute) {
return next();
}
/**
* If user cannot access the page and is not logged in, redirect to sign in
*/
if (!currentUser) {
const redirect =
to.query.redirect ||
encodeURIComponent(`${window.location.pathname}${window.location.search}`);
return next({ name: VIEWS.SIGNIN, query: { redirect } });
}
/**
* If user cannot access page but is logged in, respect sign in redirect
*/
if (to.name === VIEWS.SIGNIN && typeof to.query.redirect === 'string') {
const redirect = decodeURIComponent(to.query.redirect);
if (redirect.startsWith('/')) {
// protect against phishing
return next(redirect);
}
}
/**
* Otherwise, redirect to home page
*/
return next({ name: VIEWS.HOMEPAGE });
});
router.afterEach((to, from) => {
const telemetry = useTelemetry();
const uiStore = useUIStore();
const templatesStore = useTemplatesStore();
/**
* Run external hooks
*/
void runExternalHook('main.routeChange', useWebhooksStore(), { from, to });
/**
* Track current view for telemetry
*/
uiStore.currentView = (to.name as string) ?? '';
if (to.meta?.templatesEnabled) {
templatesStore.setSessionId();
} else {
templatesStore.resetSessionId(); // reset telemetry session id when user leaves template pages
}
telemetry.page(to);
});
export default router;

View File

@@ -31,9 +31,13 @@ import { useUIStore } from './ui.store';
import { useUsersStore } from './users.store';
import { useVersionsStore } from './versions.store';
import { makeRestApiRequest } from '@/utils';
import { useTitleChange, useToast } from '@/composables';
import { ExpressionEvaluatorProxy } from 'n8n-workflow';
import { i18n } from '@/plugins/i18n';
export const useSettingsStore = defineStore(STORES.SETTINGS, {
state: (): ISettingsState => ({
initialized: false,
settings: {} as IN8nUISettings,
promptsData: {} as IN8nPrompts,
userManagement: {
@@ -69,7 +73,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
}),
getters: {
isEnterpriseFeatureEnabled() {
return (feature: EnterpriseEditionFeature): boolean => this.settings.enterprise[feature];
return (feature: EnterpriseEditionFeature): boolean => this.settings.enterprise?.[feature];
},
versionCli(): string {
return this.settings.versionCli;
@@ -190,6 +194,33 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, {
},
},
actions: {
async initialize() {
if (this.initialized) {
return;
}
const { showToast } = useToast();
try {
await this.getSettings();
ExpressionEvaluatorProxy.setEvaluator(this.settings.expressions.evaluator);
// Re-compute title since settings are now available
useTitleChange().titleReset();
this.initialized = true;
} catch (e) {
showToast({
title: i18n.baseText('startupError'),
message: i18n.baseText('startupError.message'),
type: 'error',
duration: 0,
dangerouslyUseHTMLString: true,
});
throw e;
}
},
setSettings(settings: IN8nUISettings): void {
this.settings = settings;
this.userManagement = settings.userManagement;

View File

@@ -52,6 +52,7 @@ const isInstanceOwner = (user: IUserResponse | null) =>
export const useUsersStore = defineStore(STORES.USERS, {
state: (): IUsersState => ({
initialized: false,
currentUserId: null,
users: {},
currentUserCloudInfo: null,
@@ -122,6 +123,16 @@ export const useUsersStore = defineStore(STORES.USERS, {
},
},
actions: {
async initialize() {
if (this.initialized) {
return;
}
try {
await this.loginWithCookie();
this.initialized = true;
} catch (e) {}
},
addUsers(users: IUserResponse[]) {
users.forEach((userResponse: IUserResponse) => {
const prevUser = this.users[userResponse.id] || {};