diff --git a/web/src/components/settings/index.spec.ts b/web/src/components/settings/index.spec.ts index 917c9390df..3a41e517a3 100644 --- a/web/src/components/settings/index.spec.ts +++ b/web/src/components/settings/index.spec.ts @@ -68,9 +68,7 @@ const router = createRouter({ // Mock the router methods we need to test const mockRouterPush = vi.fn(); -const mockRouterReplace = vi.fn().mockResolvedValue(undefined); router.push = mockRouterPush; -router.replace = mockRouterReplace; const createWrapper = (props = {}, options = {}) => { return mount(SettingsIndex, { @@ -125,7 +123,6 @@ describe("SettingsIndex", () => { mockStore.state.selectedOrganization = { identifier: "test-org" }; mockStore.state.zoConfig = { service_streams_enabled: true }; mockRouterPush.mockClear(); - mockRouterReplace.mockClear(); // Set up router state await router.push("/"); @@ -238,24 +235,6 @@ describe("SettingsIndex", () => { }); describe("Router integration", () => { - it("should redirect to /settings/general on settings root route", () => { - createWrapper(); - expect(mockRouterReplace).toHaveBeenCalledWith( - expect.objectContaining({ - path: "/settings/general", - }), - ); - }); - - it("should include org_identifier in redirect query", () => { - createWrapper(); - expect(mockRouterReplace).toHaveBeenCalledWith( - expect.objectContaining({ - query: { org_identifier: "test-org" }, - }), - ); - }); - it("should map syntheticsLocations route to synthetics_locations tab", () => { // Save the original route reference so we can restore it after the test // (router.push is mocked and won't reset currentRoute on its own). @@ -393,12 +372,6 @@ describe("SettingsIndex", () => { const wrapper = createWrapper(); expect(wrapper.vm.settingsTab).toBe("general"); }); - - it("should expose isHub computed property", () => { - const wrapper = createWrapper(); - // On '/' route named 'settings', isHub is true - expect(typeof wrapper.vm.isHub).toBe("boolean"); - }); }); describe("Edge cases", () => { diff --git a/web/src/components/settings/index.vue b/web/src/components/settings/index.vue index dc38f0a37f..662f3bf9b3 100644 --- a/web/src/components/settings/index.vue +++ b/web/src/components/settings/index.vue @@ -109,12 +109,6 @@ export default defineComponent({ ); const { isMetaOrg } = useIsMetaOrg(); - // /settings (name "settings") with no child → show the hub. - const isHub = computed(() => route.name === "settings"); - const hubRoute = computed(() => ({ - name: "settings", - query: { org_identifier: store.state.selectedOrganization?.identifier }, - })); const activeSection = computed(() => routeToSettingsTab[route.name as string] ?? ""); // Form-style sections render in a centered reading column (ConstrainedPage); @@ -127,25 +121,8 @@ export default defineComponent({ ]); const isConstrainedSection = computed(() => CONSTRAINED_SECTIONS.has(activeSection.value)); - // Full-width sections that still want the shell-owned header (their content - // fills the whole width instead of a centered reading column). - // The rail is always shown, so the Settings root has no standalone landing — - // send it to the first section (General). Also guard meta-only sections. const handleSettingsRouting = () => { const name = router.currentRoute.value.name; - if (name === "settings") { - // .catch: a rejected navigation (e.g. unit-test router without child - // routes) must not surface as an unhandled error. - Promise.resolve( - router.replace({ - path: "/settings/general", - query: { - org_identifier: store.state.selectedOrganization?.identifier, - }, - }), - ).catch(() => {}); - return; - } const toGeneral = () => { settingsTab.value = "general"; router.push({ @@ -417,8 +394,6 @@ export default defineComponent({ config, settingsTab, isMetaOrg, - isHub, - hubRoute, activeSection, isConstrainedSection, activeSectionItem, diff --git a/web/src/components/settings/settingsIndex.spec.ts b/web/src/components/settings/settingsIndex.spec.ts index f4f0627328..9db46e50a3 100644 --- a/web/src/components/settings/settingsIndex.spec.ts +++ b/web/src/components/settings/settingsIndex.spec.ts @@ -219,21 +219,6 @@ describe("SettingsIndex.vue", () => { mockReplace.mockClear(); }); - it("should redirect via replace to general when on settings route", () => { - mockRouter.currentRoute.value.name = "settings"; - wrapper = createWrapper(); - mockReplace.mockClear(); - - wrapper.vm.handleSettingsRouting(); - - expect(mockReplace).toHaveBeenCalledWith({ - path: "/settings/general", - query: { - org_identifier: "test-org", - }, - }); - }); - it("should not redirect when on general route", () => { mockRouter.currentRoute.value.name = "general"; wrapper = createWrapper(); @@ -254,9 +239,7 @@ describe("SettingsIndex.vue", () => { wrapper.vm.handleSettingsRouting(); - // nodes route is not "settings" so replace is not called; - // notMeta guard may or may not apply depending on zoConfig.meta_org - // Without meta_org set, notMeta is falsy → no redirect + // Without meta_org set, the notMeta guard is falsy, so nodes is not redirected. expect(mockReplace).not.toHaveBeenCalled(); }); @@ -280,20 +263,6 @@ describe("SettingsIndex.vue", () => { expect(mockReplace).not.toHaveBeenCalled(); }); - it("should handle missing selectedOrganization gracefully", () => { - wrapper = createWrapper({ selectedOrganization: null }); - mockRouter.currentRoute.value.name = "settings"; - mockReplace.mockClear(); - - expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow(); - expect(mockReplace).toHaveBeenCalledWith({ - path: "/settings/general", - query: { - org_identifier: undefined, - }, - }); - }); - it("should not redirect when on other non-settings routes", () => { mockRouter.currentRoute.value.name = "alertDestinations"; wrapper = createWrapper(); @@ -328,17 +297,6 @@ describe("SettingsIndex.vue", () => { }); describe("Lifecycle Hooks", () => { - it("should call handleSettingsRouting on mount (settings route)", () => { - mockRouter.currentRoute.value.name = "settings"; - wrapper = createWrapper(); - - // During mount, handleSettingsRouting is called via onBeforeMount - // It should have triggered a replace call for the "settings" route - expect(mockReplace).toHaveBeenCalledWith( - expect.objectContaining({ path: "/settings/general" }), - ); - }); - it("should not redirect during mount when on a valid child route", () => { mockRouter.currentRoute.value.name = "general"; mockReplace.mockClear(); @@ -450,17 +408,6 @@ describe("SettingsIndex.vue", () => { }); describe("Integration Tests", () => { - it("should handle workflow for non-enterprise org", async () => { - mockRouter.currentRoute.value.name = "settings"; - wrapper = createWrapper(); - mockReplace.mockClear(); - - wrapper.vm.handleSettingsRouting(); - expect(mockReplace).toHaveBeenCalledWith( - expect.objectContaining({ path: "/settings/general" }), - ); - }); - it("should have stable activeSection after mount on general", () => { mockRouter.currentRoute.value.name = "general"; wrapper = createWrapper(); @@ -485,15 +432,6 @@ describe("SettingsIndex.vue", () => { wrapper = createWrapper(); expect(() => wrapper.vm.handleSettingsRouting()).not.toThrow(); }); - - it("should handle router replace failures gracefully", () => { - mockRouter.currentRoute.value.name = "settings"; - // replace returns a rejected promise — the component wraps it in Promise.resolve().catch() - mockReplace.mockReturnValue(Promise.reject(new Error("nav failed"))); - expect(() => { - wrapper = createWrapper(); - }).not.toThrow(); - }); }); describe("Computed Properties Reactivity", () => { diff --git a/web/src/composables/shared/useEnterpriseRoutes.spec.ts b/web/src/composables/shared/useEnterpriseRoutes.spec.ts index 4ede268c63..e5800765bd 100644 --- a/web/src/composables/shared/useEnterpriseRoutes.spec.ts +++ b/web/src/composables/shared/useEnterpriseRoutes.spec.ts @@ -280,6 +280,29 @@ describe("useEnterpriseRoutes.ts", () => { ]), ); }); + + // Synthetics produces an empty org's first data, so the empty-data gate must not block it. + it.each([ + "synthetics", + "synthetics-add", + "synthetics-edit", + "synthetics-status-page-edit", + "synthetic-private-location", + "synthetic-monitor-results", + "synthetics-run-detail", + ])("should flag the %s route as allowOnEmptyData", (name) => { + const routes = useEnterpriseRoutes(); + const route = routes.find((r: any) => r.name === name); + expect(route).toBeDefined(); + expect(route.meta?.allowOnEmptyData).toBe(true); + }); + + // The flag on the parent is inherited by every IAM child through merged meta. + it("should flag the iam route as allowOnEmptyData", () => { + const routes = useEnterpriseRoutes(); + const iamRoute = routes.find((route: any) => route.name === "iam"); + expect(iamRoute.meta?.allowOnEmptyData).toBe(true); + }); }); describe("Cloud Configuration", () => { @@ -386,6 +409,22 @@ describe("useEnterpriseRoutes.ts", () => { const routes = useEnterpriseRoutes(); expect(routes.length).toBe(18); }); + + // On-call is configured before any data flows, so the empty-data gate must not block it. + it.each([ + "onCallResponses", + "onCallResponseDetail", + "onCallMine", + "onCallTeams", + "onCallTeamDetail", + "onCallPolicies", + "onCallRouting", + ])("should flag the %s route as allowOnEmptyData", (name) => { + const routes = useEnterpriseRoutes(); + const route = routes.find((r: any) => r.name === name); + expect(route).toBeDefined(); + expect(route.meta?.allowOnEmptyData).toBe(true); + }); }); describe("Both Cloud and Enterprise Configuration", () => { diff --git a/web/src/composables/shared/useEnterpriseRoutes.ts b/web/src/composables/shared/useEnterpriseRoutes.ts index 07374305f0..3ac2555378 100644 --- a/web/src/composables/shared/useEnterpriseRoutes.ts +++ b/web/src/composables/shared/useEnterpriseRoutes.ts @@ -112,6 +112,7 @@ const useEnterpriseRoutes = () => { path: "iam", name: "iam", component: IdentityAccessManagement, + meta: { allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { routeGuard(to, from, next); }, @@ -215,7 +216,7 @@ const useEnterpriseRoutes = () => { path: "synthetics", name: "synthetics", component: () => import("@/views/SyntheticMonitoring.vue"), - meta: { titleKey: "menu.synthetic" }, + meta: { titleKey: "menu.synthetic", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -226,7 +227,7 @@ const useEnterpriseRoutes = () => { path: "synthetics/add", name: "synthetics-add", component: () => import("@/views/synthetics/CreateCheck.vue"), - meta: { titleKey: "routeTitles.addCheck" }, + meta: { titleKey: "routeTitles.addCheck", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -235,7 +236,7 @@ const useEnterpriseRoutes = () => { path: "synthetics/edit/:id", name: "synthetics-edit", component: () => import("@/views/synthetics/CreateCheck.vue"), - meta: { titleKey: "synthetics.results.editCheck" }, + meta: { titleKey: "synthetics.results.editCheck", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -244,7 +245,7 @@ const useEnterpriseRoutes = () => { path: "synthetics/status-pages/edit/:id", name: "synthetics-status-page-edit", component: () => import("@/views/synthetics/status-pages/StatusPageEditor.vue"), - meta: { titleKey: "statusPages.editTitle" }, + meta: { titleKey: "statusPages.editTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -253,7 +254,7 @@ const useEnterpriseRoutes = () => { path: "synthetic/private-locations/:id", name: "synthetic-private-location", component: () => import("@/views/synthetics/PrivateLocationDetail.vue"), - meta: { titleKey: "synthetics.privateLocations.detail.title" }, + meta: { titleKey: "synthetics.privateLocations.detail.title", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { privateLocationRouteGuard(to, from, next); }, @@ -262,7 +263,7 @@ const useEnterpriseRoutes = () => { path: "synthetics/:id/results", name: "synthetic-monitor-results", component: () => import("@/views/synthetics/MonitorResults.vue"), - meta: { titleKey: "synthetics.results.title" }, + meta: { titleKey: "synthetics.results.title", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -271,7 +272,7 @@ const useEnterpriseRoutes = () => { path: "synthetics/:id/results/run/:runId/:executionId", name: "synthetics-run-detail", component: () => import("@/views/synthetics/RunDetail.vue"), - meta: { titleKey: "synthetics.runDetail.title" }, + meta: { titleKey: "synthetics.runDetail.title", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { syntheticsRouteGuard(to, from, next); }, @@ -282,14 +283,13 @@ const useEnterpriseRoutes = () => { //the above are the routes that we support for oss including both enterprise and cloud if (config.isCloud == "true" || config.isEnterprise == "true") { - // Seven on-call routes, one guard, one gate expression. `titleKey` rather - // than `title` so the browser tab is translated like everything else. + // On-call is configured before any data flows, so every route here stays open on an empty org. routes.push( { path: "oncall/responses", name: "onCallResponses", component: OnCallResponses, - meta: { titleKey: "oncall.responsesTitle" }, + meta: { titleKey: "oncall.responsesTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -298,7 +298,7 @@ const useEnterpriseRoutes = () => { path: "oncall/responses/:responseId", name: "onCallResponseDetail", component: OnCallResponseDetail, - meta: { titleKey: "oncall.responseDetail" }, + meta: { titleKey: "oncall.responseDetail", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -316,7 +316,7 @@ const useEnterpriseRoutes = () => { path: "oncall/me", name: "onCallMine", component: OnCallMine, - meta: { titleKey: "oncall.mineTitle" }, + meta: { titleKey: "oncall.mineTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -325,7 +325,7 @@ const useEnterpriseRoutes = () => { path: "oncall/teams", name: "onCallTeams", component: OnCallTeams, - meta: { titleKey: "oncall.teamsTitle" }, + meta: { titleKey: "oncall.teamsTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -343,7 +343,7 @@ const useEnterpriseRoutes = () => { path: "oncall/teams/:teamId/:tab(overview|schedule|members|policy|escalation|ownership|routing)?", name: "onCallTeamDetail", component: OnCallTeamDetail, - meta: { titleKey: "oncall.teamDetail" }, + meta: { titleKey: "oncall.teamDetail", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -352,7 +352,7 @@ const useEnterpriseRoutes = () => { path: "oncall/policies", name: "onCallPolicies", component: OnCallPolicies, - meta: { titleKey: "oncall.policiesTitle" }, + meta: { titleKey: "oncall.policiesTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, @@ -361,7 +361,7 @@ const useEnterpriseRoutes = () => { path: "oncall/routing", name: "onCallRouting", component: OnCallRouting, - meta: { titleKey: "oncall.routingTitle" }, + meta: { titleKey: "oncall.routingTitle", allowOnEmptyData: true }, beforeEnter(to: any, from: any, next: any) { oncallRouteGuard(to, from, next); }, diff --git a/web/src/composables/shared/useIngestionRoutes.spec.ts b/web/src/composables/shared/useIngestionRoutes.spec.ts index 1eca98a3d5..b16e30bea4 100644 --- a/web/src/composables/shared/useIngestionRoutes.spec.ts +++ b/web/src/composables/shared/useIngestionRoutes.spec.ts @@ -143,6 +143,12 @@ describe("useIngestionRoutes", () => { expect(Array.isArray(mainRoute.children)).toBe(true); }); + // Ingestion is where an empty org gets data, so the empty-data gate must not block it. + it("should flag the ingestion route as allowOnEmptyData", () => { + const routes = useIngestionRoutes(); + expect(routes[0].meta?.allowOnEmptyData).toBe(true); + }); + it("should have all expected main child routes", () => { const routes = useIngestionRoutes(); const mainRoute = routes[0]; diff --git a/web/src/composables/shared/useIngestionRoutes.ts b/web/src/composables/shared/useIngestionRoutes.ts index 7f61596d28..13a6180778 100644 --- a/web/src/composables/shared/useIngestionRoutes.ts +++ b/web/src/composables/shared/useIngestionRoutes.ts @@ -145,6 +145,7 @@ const useIngestionRoutes = () => { component: Ingestion, meta: { titleKey: "menu.ingestionText", + allowOnEmptyData: true, }, beforeEnter(to: any, from: any, next: any) { routeGuard(to, from, next); diff --git a/web/src/composables/shared/useManagementRoutes.spec.ts b/web/src/composables/shared/useManagementRoutes.spec.ts index 8721ed4389..dd696f8ce0 100644 --- a/web/src/composables/shared/useManagementRoutes.spec.ts +++ b/web/src/composables/shared/useManagementRoutes.spec.ts @@ -122,6 +122,15 @@ describe("useManagementRoutes", () => { const routes = useManagementRoutes(); expect(routes[0].children.length).toBeGreaterThanOrEqual(4); }); + + // Bare /settings never lands; the record redirects to general and the query must survive the hop. + it("should redirect settings to general, preserving the query", () => { + const routes = useManagementRoutes(); + expect(routes[0].redirect({ query: { org_identifier: "o" } })).toEqual({ + name: "general", + query: { org_identifier: "o" }, + }); + }); }); describe("Base Children Routes", () => { @@ -137,6 +146,12 @@ describe("useManagementRoutes", () => { expect(generalRoute.path).toBe("general"); }); + // General hosts the Danger Zone; an admin must reach it to delete an empty org. + it("should flag the general route as allowOnEmptyData", () => { + const generalRoute = routes[0].children.find((child: any) => child.name === "general"); + expect(generalRoute.meta?.allowOnEmptyData).toBe(true); + }); + it("should have organizationSettings route", () => { const orgRoute = routes[0].children.find( (child: any) => child.name === "organizationSettings", @@ -398,9 +413,19 @@ describe("useManagementRoutes", () => { expect(synthRoute.meta).toEqual({ keepAlive: true, titleKey: "routeTitles.syntheticsLocations", + allowOnEmptyData: true, }); }); + // Locations are a prerequisite of the first check, so the empty-data gate must not block them. + it("should flag the syntheticsLocations route as allowOnEmptyData", () => { + const routes = useManagementRoutes(); + const synthRoute = routes[0].children.find( + (child: any) => child.name === "syntheticsLocations", + ); + expect(synthRoute.meta?.allowOnEmptyData).toBe(true); + }); + // Synthetics ships in OSS, so the route registers in every build — the // feature gate is the backend `/config` flag, not the build type. it("should have syntheticsLocations route in an OSS build", () => { diff --git a/web/src/composables/shared/useManagementRoutes.ts b/web/src/composables/shared/useManagementRoutes.ts index 7f635d1a43..99a1c420ed 100644 --- a/web/src/composables/shared/useManagementRoutes.ts +++ b/web/src/composables/shared/useManagementRoutes.ts @@ -13,6 +13,7 @@ const useManagementRoutes = () => { keepAlive: true, titleKey: "menu.settings", }, + redirect: (to: any) => ({ name: "general", query: to.query }), beforeEnter(to: any, from: any, next: any) { routeGuard(to, from, next); }, @@ -22,6 +23,7 @@ const useManagementRoutes = () => { name: "general", meta: { titleKey: "settings.groupGeneral", + allowOnEmptyData: true, }, component: () => import("@/components/settings/General.vue"), beforeEnter(to: any, from: any, next: any) { @@ -73,9 +75,11 @@ const useManagementRoutes = () => { path: "synthetics_locations", name: "syntheticsLocations", component: () => import("@/components/settings/SyntheticsLocationsList.vue"), + // Locations are set up before the first check can run, so an empty org must reach this. meta: { keepAlive: true, titleKey: "routeTitles.syntheticsLocations", + allowOnEmptyData: true, }, beforeEnter(to: any, from: any, next: any) { routeGuard(to, from, next); diff --git a/web/src/layouts/MainLayout.vue b/web/src/layouts/MainLayout.vue index ae43154011..c5a30f6c9b 100644 --- a/web/src/layouts/MainLayout.vue +++ b/web/src/layouts/MainLayout.vue @@ -228,7 +228,7 @@ import { getImageURL, invalidateLoginData, shouldPaywallRoute, - emptyDataAllowedPaths, + isEmptyDataExempt, } from "../utils/zincutils"; import { @@ -954,18 +954,7 @@ export default defineComponent({ }); if (response.list.length == 0) { store.dispatch("setIsDataIngested", false); - // IAM is org-setup, not data consumption — don't bounce out of IAM - // screens just because no streams exist yet. General Settings is exempt - // because it hosts the Danger Zone: switching to an empty org must still - // leave the admin able to delete it. Mirrors the routeGuard exemptions — - // General only, not the rest of the Settings tree. - const currentPath = router.currentRoute.value.path || ""; - if ( - currentPath.indexOf("/iam") !== -1 || - emptyDataAllowedPaths.indexOf(currentPath.replace(/\/$/, "")) !== -1 - ) { - return; - } + if (isEmptyDataExempt(router.currentRoute.value)) return; toast({ variant: "warning", message: t("toastMessages.layouts.ingestionNotStarted"), diff --git a/web/src/types/vue-router.d.ts b/web/src/types/vue-router.d.ts new file mode 100644 index 0000000000..3d659da310 --- /dev/null +++ b/web/src/types/vue-router.d.ts @@ -0,0 +1,22 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import "vue-router"; + +declare module "vue-router" { + interface RouteMeta { + allowOnEmptyData?: boolean; + } +} diff --git a/web/src/utils/auth.spec.ts b/web/src/utils/auth.spec.ts index cf1d6b2cc1..469b249da1 100644 --- a/web/src/utils/auth.spec.ts +++ b/web/src/utils/auth.spec.ts @@ -53,6 +53,7 @@ import { trialPeriodAllowedPath, trialPaywallAllowedPath, isTrialExpired, + isEmptyDataExempt, getUserInfo, invalidateLoginData, getDecodedAccessToken, @@ -133,7 +134,6 @@ describe("trialPeriodAllowedPath", () => { expect(trialPeriodAllowedPath).toHaveLength(4); }); - // The empty-data guard reads this list, so "general" must stay out of it. it("does not contain general", () => { expect(trialPeriodAllowedPath).not.toContain("general"); }); @@ -152,12 +152,31 @@ describe("trialPeriodAllowedPath", () => { trialPeriodAllowedPath.forEach((p) => expect(trialPaywallAllowedPath).toContain(p)); }); - // The empty-data guard reads this list, so the shell must stay out of it. - it("does not contain the settings shell in the empty-data list", () => { + it("does not contain the settings shell", () => { expect(trialPeriodAllowedPath).not.toContain("settings"); }); }); +// --------------------------------------------------------------------------- +// isEmptyDataExempt +// --------------------------------------------------------------------------- + +describe("isEmptyDataExempt", () => { + it("is true when the route meta carries allowOnEmptyData: true", () => { + expect(isEmptyDataExempt({ meta: { allowOnEmptyData: true } })).toBe(true); + }); + + // Merged meta is child-over-parent, so a child can re-gate itself with `false`. + it.each([ + ["a route with no meta", {}], + ["a route whose meta lacks the flag", { meta: { titleKey: "menu.logs" } }], + ["a child override of false", { meta: { allowOnEmptyData: false } }], + ["a truthy non-boolean flag", { meta: { allowOnEmptyData: "true" } }], + ])("is false for %s", (_label, route) => { + expect(isEmptyDataExempt(route)).toBe(false); + }); +}); + // --------------------------------------------------------------------------- // getUserInfo // --------------------------------------------------------------------------- @@ -406,7 +425,6 @@ describe("routeGuard", () => { expect(mockNext).toHaveBeenCalledWith(expect.objectContaining({ name: "plans" })); }); - // The nav resolves to name "settings", not "general"; paywalling it hides the feature. it("lets the nav settings shell through", async () => { (config as any).isCloud = "true"; mockStore = buildExpiredTrialStore(); @@ -520,7 +538,7 @@ describe("routeGuard", () => { }, }); - // emptyDataAllowedPaths is what exempts /settings/general here, not the name list. + // The route's own meta flag is what exempts /settings/general here, not its name. it("lets /settings/general through on a no-data org", async () => { (config as any).isCloud = "false"; vi.mocked(organizationService.get_organization_summary).mockResolvedValue({ @@ -529,13 +547,18 @@ describe("routeGuard", () => { mockStore = buildNoDataStore(); vi.mocked(useStore).mockReturnValue(mockStore as any); - await routeGuard({ name: "general", path: "/settings/general" }, {}, mockNext); + await routeGuard( + { name: "general", path: "/settings/general", meta: { allowOnEmptyData: true } }, + {}, + mockNext, + ); + expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledWith(); expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" }); }); - // Same name, uncovered path: fails if the name list ever does the exempting. + // Same name as the flagged fixture above, no flag: fails if the name ever does the exempting. it("does not exempt a general-named route at an uncovered path", async () => { (config as any).isCloud = "false"; vi.mocked(organizationService.get_organization_summary).mockResolvedValue({ @@ -635,7 +658,11 @@ describe("routeGuard", () => { }); vi.mocked(useStore).mockReturnValue(mockStore as any); - await routeGuard({ name: "ingestion", path: "/ingestion" }, {}, mockNext); + await routeGuard( + { name: "ingestion", path: "/ingestion", meta: { allowOnEmptyData: true } }, + {}, + mockNext, + ); expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledTimes(1); @@ -655,7 +682,11 @@ describe("routeGuard", () => { }); vi.mocked(useStore).mockReturnValue(mockStore as any); - await routeGuard({ name: "iam", path: "/iam" }, {}, mockNext); + await routeGuard( + { name: "iam", path: "/iam", meta: { allowOnEmptyData: true } }, + {}, + mockNext, + ); expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledTimes(1); @@ -673,32 +704,7 @@ describe("routeGuard", () => { }, }); - // /settings/general hosts the Danger Zone, and an org with nothing ingested is - // the one an admin is most likely to delete — bouncing to /ingestion would - // leave no way to. "/settings" is the nav's landing path before it redirects - // to general, so it has to survive the guard too. - it.each([ - ["settings landing", "settings", "/settings"], - ["general settings", "general", "/settings/general"], - ["general settings, trailing slash", "general", "/settings/general/"], - ])("calls next() directly for %s", async (_label, name, path) => { - (config as any).isCloud = "false"; - vi.mocked(organizationService.get_organization_summary).mockResolvedValue({ - data: { streams: { num_streams: 0 } }, - }); - mockStore = buildEmptyDataStore(); - vi.mocked(useStore).mockReturnValue(mockStore as any); - - await routeGuard({ name, path }, {}, mockNext); - - expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); - expect(mockNext).toHaveBeenCalledTimes(1); - expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" }); - }); - - // The exemption is deliberately only the two paths above — the rest of the - // Settings tree shows no ingested data either, but it is not needed to escape - // an empty org, so it stays behind the ingestion redirect. + // Settings children carry no flag, so they stay gated even though they show no data. it.each([ ["organizationSettings", "/settings/organization"], ["license", "/settings/license"], @@ -716,19 +722,26 @@ describe("routeGuard", () => { expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" }); }); - // I14: on-call is configured BEFORE any data flows — teams, schedules and - // routing are exactly what a fresh org sets up first. Bouncing those clicks - // to /ingestion made every on-call screen unreachable by navigation (a - // direct URL worked only because a cold load races zoConfig). The whole - // subtree is exempt, including the dynamic-segment routes the exact-match - // list cannot express. + // Synthetics produces an empty org's first data and on-call is set up before any flows. it.each([ - ["triage list", "onCallResponses", "/oncall/responses"], - ["team list", "onCallTeams", "/oncall/teams"], - ["team detail with params", "onCallTeamDetail", "/oncall/teams/team_1/schedule"], - ["org routing", "onCallRouting", "/oncall/routing"], - ["trailing slash", "onCallTeams", "/oncall/teams/"], - ])("calls next() directly for on-call %s", async (_label, name, path) => { + ["synthetics", { name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } }], + [ + "synthetic-private-location", + { + name: "synthetic-private-location", + path: "/synthetic/private-locations/abc", + meta: { allowOnEmptyData: true }, + }, + ], + [ + "onCallTeamDetail", + { + name: "onCallTeamDetail", + path: "/oncall/teams/team_1/schedule", + meta: { allowOnEmptyData: true }, + }, + ], + ])("calls next() directly for the flagged %s route", async (_label, route) => { (config as any).isCloud = "false"; vi.mocked(organizationService.get_organization_summary).mockResolvedValue({ data: { streams: { num_streams: 0 } }, @@ -736,16 +749,84 @@ describe("routeGuard", () => { mockStore = buildEmptyDataStore(); vi.mocked(useStore).mockReturnValue(mockStore as any); - await routeGuard({ name, path }, {}, mockNext); + await routeGuard(route, {}, mockNext); expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); expect(mockNext).toHaveBeenCalledTimes(1); - expect(mockNext).not.toHaveBeenCalledWith({ path: "/ingestion" }); + expect(mockNext).toHaveBeenCalledWith(); }); - // The prefix is a path SEGMENT, not a substring — a route that merely - // starts with the same letters must not inherit the exemption. - it("still redirects a near-miss path that only shares the prefix's letters", async () => { + // The flag exempts a route from the empty-data gate only, never from the paywall. + it("still redirects a flagged route to plans when the cloud trial is expired", async () => { + (config as any).isCloud = "true"; + mockStore = buildMockStore({ + state: { + organizationData: { + organizationSettings: { + free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000, + }, + isDataIngested: false, + }, + selectedOrganization: { identifier: "my-org" }, + zoConfig: { restricted_routes_on_empty_data: true }, + }, + }); + vi.mocked(useStore).mockReturnValue(mockStore as any); + + await routeGuard( + { name: "synthetics", path: "/synthetics", meta: { allowOnEmptyData: true } }, + {}, + mockNext, + ); + + expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith({ + name: "plans", + query: { org_identifier: "my-org" }, + }); + }); + + // Paywall runs before the empty-data gate; an empty org must not turn a paywall into a bounce. + it("still redirects an unflagged route to plans when the cloud trial is expired on an empty org", async () => { + (config as any).isCloud = "true"; + mockStore = buildMockStore({ + state: { + organizationData: { + organizationSettings: { + free_trial_expiry: (Date.now() - 30 * 24 * 60 * 60 * 1000) * 1000, + }, + isDataIngested: false, + }, + selectedOrganization: { identifier: "my-org" }, + zoConfig: { restricted_routes_on_empty_data: true }, + }, + }); + vi.mocked(useStore).mockReturnValue(mockStore as any); + + await routeGuard({ name: "logs", path: "/logs" }, {}, mockNext); + + expect(organizationService.get_organization_summary).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith({ + name: "plans", + query: { org_identifier: "my-org" }, + }); + }); + + // Only the meta flag exempts; a matching name or path prefix does not. + it.each([ + ["an unflagged general route", { name: "general", path: "/settings/general" }], + ["an unflagged route on a synthetics path", { name: "x", path: "/synthetics/foo" }], + ["an unflagged near-miss of the on-call path", { name: "oncallish", path: "/oncallish" }], + ["a route named users under /iam", { name: "users", path: "/iam/users" }], + ["an unflagged route named iam", { name: "iam", path: "/access" }], + [ + "an unflagged route under /ingestion", + { name: "curl", path: "/ingestion/custom/logs/curl" }, + ], + ["the unflagged settings shell", { name: "settings", path: "/settings" }], + ])("redirects %s to /ingestion", async (_label, route) => { (config as any).isCloud = "false"; vi.mocked(organizationService.get_organization_summary).mockResolvedValue({ data: { streams: { num_streams: 0 } }, @@ -753,8 +834,9 @@ describe("routeGuard", () => { mockStore = buildEmptyDataStore(); vi.mocked(useStore).mockReturnValue(mockStore as any); - await routeGuard({ name: "oncallish", path: "/oncallish" }, {}, mockNext); + await routeGuard(route, {}, mockNext); + expect(mockNext).toHaveBeenCalledTimes(1); expect(mockNext).toHaveBeenCalledWith({ path: "/ingestion" }); }); }); diff --git a/web/src/utils/auth.ts b/web/src/utils/auth.ts index 20c695a852..6b0e9b7993 100644 --- a/web/src/utils/auth.ts +++ b/web/src/utils/auth.ts @@ -2,39 +2,15 @@ import config from "../aws-exports"; import { useStore } from "vuex"; +import type { RouteMeta } from "vue-router"; import userService from "@/services/users"; import organizationService from "@/services/organizations"; import { b64DecodeUnicode, b64EncodeStandard, b64DecodeStandard } from "@/utils/formatters"; import { useLocalUserInfo } from "@/utils/storage"; import { getUUID, getUUIDv7 } from "@/utils/uuid"; -// Exact paths that stay reachable when the org has ingested nothing yet. The -// empty-data redirect exists to push people toward ingestion rather than show -// them empty data views, and that still applies to most of Settings — but an org -// with nothing ingested is precisely the one an admin is most likely to want to -// delete, and the Danger Zone lives on /settings/general. "/settings" is included -// because the nav's Settings entry lands there before redirecting to general. -// Matched exactly, not by prefix, so the rest of the Settings tree stays gated. -export const emptyDataAllowedPaths = ["/settings", "/settings/general"]; - -// Route SUBTREES that stay reachable with nothing ingested. On-call is -// configured before data flows — teams, schedules, escalation and routing are -// exactly what a fresh org sets up first, and bouncing those clicks to -// /ingestion made every on-call screen unreachable by navigation (I14). A -// prefix list because these routes carry dynamic segments (:teamId, :tab), -// which the exact-match list above cannot express. A prefix admits its whole -// subtree — add a screen's root here only when everything under it is -// configuration rather than a data view. -export const emptyDataAllowedPrefixes = ["/oncall"]; - -// "/settings/general/" and "/settings/general" are the same page; an exact match -// must not hinge on a trailing slash. -const normalizePath = (path: string) => - path !== "/" && path.endsWith("/") ? path.slice(0, -1) : path; - export const trialPeriodAllowedPath = ["iam", "users", "organizations", "invitations"]; -// Kept out of the empty-data guard; "settings" only redirects on to general. export const trialPaywallAllowedPath = [...trialPeriodAllowedPath, "settings", "general"]; export const getUserInfo = (loginString: string) => { @@ -133,6 +109,10 @@ export const shouldPaywallRoute = (expiry: unknown, routeName: unknown): boolean isTrialExpired(expiry) && trialPaywallAllowedPath.indexOf(routeName as string) === -1; +// Org-setup and data-producing surfaces declare their empty-data exemption per route, in meta. +export const isEmptyDataExempt = (route: { meta?: RouteMeta | Record }): boolean => + route.meta?.allowOnEmptyData === true; + export const routeGuard = async (to: any, from: any, next: any) => { const store = useStore(); if ( @@ -151,15 +131,7 @@ export const routeGuard = async (to: any, from: any, next: any) => { } if ( - to.path.indexOf("/ingestion") === -1 && - to.path.indexOf("/iam") === -1 && - to.name !== "iam" && - emptyDataAllowedPaths.indexOf(normalizePath(to.path)) === -1 && - !emptyDataAllowedPrefixes.some( - (prefix) => - normalizePath(to.path) === prefix || normalizePath(to.path).startsWith(prefix + "/"), - ) && - trialPeriodAllowedPath.indexOf(to.name) === -1 && + !isEmptyDataExempt(to) && Object.prototype.hasOwnProperty.call(store.state.zoConfig, "restricted_routes_on_empty_data") && store.state.zoConfig.restricted_routes_on_empty_data === true && store.state.organizationData.isDataIngested === false