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